[{"title":"C语言——猜数字小游戏","slug":"c-games/number-guessing-game","permalink":"/kb/posts/c-games/number-guessing-game","category":"c-games","description":"这是这款小游戏的简单玩法。期待着与你一同完善，改进这个小游戏！","date":"2026-06-16T00:00:00.000Z","content":"C语言——猜数字小游戏 如何用rand，srand，time来完成随机数发生 这是这款小游戏的简单玩法。期待着与你一同完善，改进这个小游戏！ 接下来我们看一下如何来实现这样一个游戏。 纵观这个游戏，我们发现，这个游戏的难点其实就是 如何生成一个随机数 。 生成随机数我们这里用到了三个个函数分别是： rand() srand() time() 由于生成随机数是我们这个游戏的核心，我们把这三个函数在这里细讲一下 int rand() 头文件 ：stdlib.h 定义 ：srand() 播种 rand() 所用的伪随机数生成器。若在任何对 srand() 的调用前使用 rand() ，则 rand() 表现如同它以 srand(1) 播种。每次以 srand() 播种 rand() 时，它必须产生相同的值数列。 返回值 ：返回 ​0​ 与 RAND_MAX 间的随机整数值（包含 0 与 RAND_MAX ）。 void srand( unsigned seed ) 头文件 ：stdlib.h 定义 ：以值 seed 播种 rand() 所用的随机数生成器。若在任何到 srand() 的调用前使用 rand() ，则 rand() 表现为如同它被以 srand(1) 播种。每次以同一 seed 播种 rand() 时，它必须产生相同的值数列。 返回值 ：无 是不是听了之后很懵逼？没关系，请看下面的例子 time_t time( time_t \\*arg ) 头文件 ：time.h 定义 ：返回编码成 time_t 对象的当前日历时间，并将其存储于 arg 指向的 time_t 对象（除非 arg 为空指针） 参数 ：arg - 指向将存储时间的 time_t 对象的指针，或空指针 返回值 ：成功时返回编码成 time_t 对象的当前日历时间。错误时返回 (time_t)(-1) 。若arg不是空指针，则返回值也会存储于 arg 所指向的对象。 需要注意的是，在我的32位的vs2019编译器上，time_t的类型是long long 对于time的用法请看下例 综上所述，我们把time函数返回的随时变化的时间戳（从1970年1月1日至今）当作srand函数的参数，这样就可以让rand每次产生的数列都是不同的，随机的 切记要在使用rand函数前先调用srand函数哦！ 它们组合起来怎么写呢？我们可以这么来写： 想必大家看到了我的程序还有一个让你选择猜数范围的功能，那么它又是怎么实现的呢？ 其实也很简单，核心就是 求余 假如你想猜数的范围是1 10 rand函数产生数是随机数，用 rand() % 10 得到的范围是 0 9 那就再给它加上1就好了，其他范围都是这个道理 [猜数字游戏完整源代码]( https://github.com/hairrrrr/project/tree/master/C games/guessing-number)"},{"title":"C语言概述","slug":"c-modern-approach/01-overview","permalink":"/kb/posts/c-modern-approach/01-overview","category":"c-modern-approach","description":"One man's constant is another man's variable。[^1]","date":"2026-06-16T00:00:00.000Z","content":"C语言概述 One man's constant is another man's variable。 1 :arrow_forward: 此符号表示该内容以后的章节会讲解，此章节内不要求理解。 本节内容 C语言的历史，C语言的优缺点以及如何高效的使用C语言 C语言还值得学习吗？C语言查错的工具 C语言的历史 起源 C语言是贝尔实验室的 Ken Thompson, Dennis Ritchie 等人开发的 UNIX 操作系统的“副产品”。 与同时代的其他操作系统一样，UNIX 系统最初也是用汇编语言写的。用汇编语言编写的程序往往难以调试和改进，UNIX 操作系统也不例外。Thompson 意识到需要用一种高级的编程语言来完成 UNIX 系统未来的开发，于是他设计了一种小型的 B语言。Thompson 的 B语言是在 BCPL语言（20世纪60年代中期产生的一种系统编程语言）的基础上开发的，而 BCPL语言又可以追溯到最早（且影响深远）的语言之一——Algol 60语言。 1970年，贝尔实验室为 UNIX 项目争取到了一台 PDP-11 计算机。当 B语言经过改进并能够在 PDP-11 计算机上成功运行后，Thompson 用 B语言重新编写了部分 UNIX 代码。 到了1971年，B语言已经明显不适合 PDP-11 计算机了，于是 Ritchie 着手开发 B语言的升级版。最初他将新开发的语言命名为 NB语言（意味New B），但是后来新语言越来越偏离 B语言，于是他将其改名为 C语言。 到1973年，C语言已经足够稳定，可以用来重新编写 UNIX 系统了。 标准化 C语言在20世纪七十年代（尤其是1977年到1979）持续发展。这一时期出现了第一本有关 C语言的书。Brian Kernighan 和 Dennis Ritchie 合作编写的 The C Programming Language 于1978年出版，并迅速成为 C程序员必读的“圣经”。由于当时没有 C语言的正式标准，这本书就成为了事实上的标准，编程爱好者把它称为“K&#x26;R”或者“白皮书”。（公众号后台回复：【KR】即可获得） 随着C语言的迅速普及，一系列问题也接踵而至。首先， K&#x26;R 对一些语言特性描述得非常模糊，以至于不同编译器对这些特性会做出不同的处理。而且，K&#x26;R 也没有对属于 C语言的特性和属于 UNIX 系统的的特性进行明确的区分。更糟糕的是，K&#x26;R 出版后 C语言仍然在不断变化，增加了一些新特性并除去了一些旧特性。很快，C语言需要一个全面，准确的最新描述开始成为共识。 C89/C90 1983年，在美国国家标准协会（ANSI）的推动下（ANSI 于此年组建了一个委员会称为 X3Jll），美国开始制定本国的 C语言标准。 1988年完成并于1989年12月正式通过的 C语言标准成为 ANSI 标准 X3.159-1989。 1990年，国际标准化组织（ISO）通过了此项标准，将其作为 ISO/IEC 9899:1990 国际标准（中国国家标准为 GB/T 15272—1994）。 我们把这一C语言版本称为 C89 或 C90 ，以区别原始的 C语言版本。 委员会在制定的指导原则中的一条写道：保持 C 的精神。委员会在描述这一精神时列出了一下几点： 信任程序员 不要妨碍程序员做需要做的事 保持语言精炼简单 只提供一种方法执行一项操作 让程序运行更快，即使不能保持其可移植性 在最后一点上，标准委员会的用意是：作为实现，应该针对目标计算机来定义最合适的某特定操作，而不是强加一个抽象，统一的定义。在学习 C语言的过程中，许多方面都反映了这一哲学思想。 C99 1995 年，C语言发生了一些改变。 1999年通过的 ISO/IEC 9899：1999 新标准中包含了一些更重要的改变，这一标准所描述的语言通常称为 C99 此次改变，委员会的用意不是在C语言中添加新的特性，而是为了达到新的目标。 支持国际化编程 。如：提供多种方法处理国际字符集 调整现有实践致力于解决明显的缺陷 。因此，在遇到需要将 C移至64位处理器时，委员会根据现实生活中处理问题的经验来添加标准。 为 适应科学和工程项目中的关键计算 ，提高 C 的适应性，让 C 比 FORTRAN 更有竞争力。 其他方面的改变则更为保守，如，尽量让C90，C++兼容，让语言在概念上保持简单。 虽然改标准已经发布了很长时间，但 并非所有编译器都完全支持C99 的所有改动。因此，你有可能发现 C99 的一些改动在自己的系统中不可用，或者需要改变编译器的设置才可用。 C11 2011年， C11 标准问世。 基于C的语言 C++：包含所有C的特性 Java：基于C++，所以也继承了C的许多特性 C#：由C++于java发展起来的较新的语言 Perl：最初是一种简单的脚本语言，在发展过程中采用了C的许多特性 C语言还值得学吗？ 答案是肯定的。 第一，学习C有助于更好的理解C++，Java，C#，Perl以及其他基于C的特性的语言。第一开始就学习其他语言的程序员往往不能很好的掌握继承自C语言的基本特性。 第二，目前仍有许多C程序，我们需要读懂并维护这些代码。 第三，C语言仍广泛应用于新软件的开发，特别是在内存或处理能力受限的情况下以及需要使用C语言简单特性的地方。 C语言的优缺点 与其他任何一种编程语言一样，C语言也有自己的优缺点。这些优缺点都源于该语言的最初用途（编写操作系统和其它系统软件）和它自身的基础理论体系。 C语言是一种底层语言 为了适应系统编程的需要，C语言提供了对机器级概念（例如，字节和地址）的访问，而这些都是其他编程语言试图隐藏的内容。 C语言是一种小型语言 与许多其他编程语言相比，C语言提供了一套更有限特性集合。（在K&#x26;R第二版的参考手册中仅用49页就描述了整个C语言。）为了使特性较少，C语言在很大程度上依赖一个标准函数的“库”。 C是一种包容性语言 C假设用户知道自己在干什么，因此它提供了比其他许多语言更广阔的自由度。此外，C语言不像其他语言那样强制进行详细的错误检查。 C语言的优点 C语言的众多优点解释了C语言为何如此流行。 高效 高效性是C语言与生俱来的优点之一。发明C语言就是为了编写那些以往由汇编语言编写的程序，所以对C语言来说，能够在有限的内存空间快速运行就显得至关重要。 可移植 当程序必须在多种机型（从个人计算机到超级计算机）上运行时，常常会用C语言来编写。 原因一：C语言没有分裂成不兼容的多种分支。这主要归功于C语言早期与UNIX系统的结合以及后来的ANSI/ISO标准。 原因二：C语言编译器规模小且容易编写，这使得它们得以广泛应用。 原因三：C语言的自身特性也支持可移植性（尽管它没有阻止程序员编写不可移植的程序）。 功能强大 C语言拥有一个庞大的数据类型和运算符集合，这个集合使得C语言具有强大的表达能力，往往寥寥几行代码就可以实现许多功能。 灵活 C语言最初设计是为了系统编程，但没有固有的约束将其限制在此范围内。C语言现在可以用于编写从嵌入式系统到商业数据处理的各种应用程序。 标准库 C语言的突出优点就是它具有标准库，该标准库包括了数百个可以用于输入/输出，字符串处理，储存分配以及其他实用操作的函数。 与UNIX的集成 C语言在与UNIX系统（包括Linux）结合方面特别强大。事实上，一些UNIX工具甚至假设用户是了解C语言的。 C语言的缺点 C语言容易隐藏错误 C语言的灵活性使得用它编程出错的概率极高。在用其他语言时可以发现的错误，C语言的编译器却无法检查到。更糟糕的是，C语言还包含大量不易察觉的隐患。 C程序可能难以理解 C程序的简明扼要与灵活性，可能导致程序员编写出除了自己别人无法读懂的代码。 C程序可能难以修改 如果在设计中没有考虑到维护的问题，那么C编写的大型程序可能很难修改。现代的编程语言通常提供“类”和“包”之类的语言特性，这样的特性可以把大的程序分解成许多更容易管理的模块。遗憾的是，C语言恰恰缺少这样的特性。 高效的使用C语言 要高效的使用C语言，就需要利用C语言优点的同时尽量避免它的缺点，一下给出一些建议。 学习如何规避C语言的缺陷 使用软件工具使程序更可靠 （详细见下文） 利用现有的代码库 使用C语言的一个好处是其他许多人也在使用C。把别人编写好的代码用于自己的程序是一个非常好多主意。C代码通常被打包成库（函数的集合）。获取适当的库既可以大大减少错误，也可以节省很多编程工作。 采用一套切合实际的编码规范 良好的编码习惯和规范易于自己和他人对自己代码的阅读和修改。（公众号回复：【编码规范】，让你学会如何写出规范的代码。） 避免“投机取巧”和极度复杂的代码 。C语言鼓励使用编程技巧。但是，过犹不及，不要对技巧毫无节制，最简单的解决方案往往也是最难理解的。 紧贴标准 大多数编译器都提供不属于 C89/C99 标准的特征和库函数。为了程序的可移植性，若非确有必要，最好避免这些特性和库函数。 怎么让程序更加安全可靠？ 分析错误工具——lint 越界检查工具——bounds-checker 内存泄漏监测工具——leak-finder 调高你的编译器的“警告级别” 以上就是本次的内容，感谢观看。 如果文章有错误欢迎指正和补充，感谢！ 最后，如果你还有什么问题或者想知道到的，可以在评论区告诉我呦，我在后面的文章可以加上。 最后， 关注我 ，看更多干货！ 我是程序圆，我们下次再见。 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 吾之常量，彼之变量。摘自《epigrams-on-programming》 ↩"},{"title":"C语言基本概念","slug":"c-modern-approach/02-basic-concepts","permalink":"/kb/posts/c-modern-approach/02-basic-concepts","category":"c-modern-approach","description":"Syntactic sugar causes cancer of the semi-colons.[^0]","date":"2026-06-16T00:00:00.000Z","content":"C语言基本概念 Syntactic sugar causes cancer of the semi-colons. 1 :globe_with_meridians:目录 [TOC] :books:教学 第一个C程序 main.c 将上述程序写在你的编译器里。 然后给文件命名，并以 .c 作为扩展名，例如 main.c 。 现在，编译并运行该程序。如果一切正常，输出的应该是： 恭喜你，你已经是一名C程序员了！:laughing: Hello，World 是伟大的。它像着一个呱呱坠地的婴儿对世界的问好，它憧憬着美好的世界，一切事物都是新鲜的。 ​ ——不会编程的程序圆 现在，让我们看看这个程序到底是什么意思。 正式开始之前 编译和链接 C程序编译链接的过程：（知道即可） 集成开发环境 集成开发环境（integrated development enviroment,IDE）：集成开发环境是一个软件包，我们可以在其中编辑，编译，链接，执行和调试程序。 IDE推荐： CodeBlock（本教学中的简单的程序会用这个软件来完成） VS2019（编写需要调试的程序用它来完成） 简单程序的一般形式 1. 指令 示例程序第一行 #include&#x3C;stdio.h> 就是一条指令。 在程序 编译之前 ，C编译器的 预处理器 （preprocessor）会首先对源代码进行一些准备工作，即预处理（preprocessing）。 **指令(directive)：**我们把 预处理器 执行的命令称为 预处理器指令（preprocessor directive），简称指令。 指令的结尾不需要添加分号 #include&#x3C;stdio.h> 的作用相当于把 头文件 stdio.h 中的所有内容都输入到该行所在的位置。 实际上，这是一种 复制+粘贴 的操作。 include 文件提供了一种方便的途径共享许多程序共有的信息 。 stdio.h 文件中包含了供编译器使用的输入和输出函数（如 printf() ）信息。 该文件名的含义为 标准输入/输出 头文件（stadard input&#x26;output .header） **头文件(header):**在C程序顶部的信息集合。 每个头文件都包含一些标准库的内容。 示例程序引入stdio.h头文件的原因：C语言不同于其他编程语言，它没有内置的“读/写”命令。输入/输出功能由标准库中的函数实现。 2 **每次用到库函数，必须用#include指令包含相关的头文件。**省略必要的头文件可能不会对某一个特定程序造成影响，但是最好不要这样做。 2.函数 int main(void) **函数：**类似于其他编程语言的“过程”或“子例程”，它们是用来构建程序的构建块。 事实上，C语言就是函数的集合。 函数分两大类：第一种是程序员自己编写的函数；另一类则是C作为语言实现的一部分提供的函数，即 库函数 （library function）。因为它们属于一个由编译器提供的函数“库”。 main函数 ：C程序都是从 main() 函数“开始”执行。 main() 函数是程序的唯一入口。 可以理解为程序是从main函数开始运行到main函数结尾结束。 返回类型 ： int 是main函数的 返回类型。这表明 main函数返回的值是整型。 返回给哪里？返回给操作系统，我们后面再来讲解 参数 ： () 内包含的信息为函数的参数。示例中的 void 表示该例中没有传入任何参数。 请注意 有背景颜色的地方都是重要的知识，但是在这里不管你是初学者/学了一段时间了，我都建议你遵守以下规范： **main函数到底应该怎么写？**我在这里不详细展开说。 正确的形式 ： int main(int argc, char* argv[]) 可以接受的形式： int main(void) 错误的写法 ： int main() 谭老师书中的写法。跟我学，不要用这种写法 脑瘫的写法 ： void main() void main(void) 所有C语言的标准都未认可这种写法，你在赣神魔？ return 0 返回值 ：前面我们讲到了 返回类型 ，那么就应该有个返回值。示例中 return 就代表返回， 0 是这个main函数的返回值。 main函数中return的作用 ： 1. 使main函数终止 。mian函数在这里结束。 2.main函数返回值是0， 表示程序正常终止 。 所以， return 0 在main函数中是不可省略的 虽然即使你不写，可能也可以通过编译，但是不写是不对的。 3.语句 语句是程序运行时执行的命令 语句是带顺序执行的 C 程序段。任何函数体都是一条复合语句，继而为语句或声明的序列 例如： C语言中的六种语句 标号语句 goto 语句的目标。 （ 标识符 : 语句 ） switch 语句的 case 标号。（ case 常量表达式 : 语句 ） switch 语句的默认标号。 （ default : 语句 ） 复合语句 复合语句，或称 块 ，是 花括号 所包围的语句与声明的序列。 {声明（可选）| 语句 } 表达式语句 典型的 C 程序中大多数语句是表达式语句，例如赋值或函数调用。 无表达式的表达式语句被称作 空语句 。它通常用于提供空循环体给 for 或 while 循环。 选择语句 选择语句根据表达式的值，选择数条语句之一执行。 if 语句 if 语句带 else 子句 switch 语句 迭代语句 迭代语句重复执行一条语句。 while 循环 do-while 循环 for 循环 跳转语句 跳转语句无条件地转移控制流。 break 语句 continue 语句 return 语句带可选的表达式 goto 语句 为什么需要分号？ 由于语句可以连续占用多行，有时很难确定它结束的位置，因此需要用分号来向编译器表示语句结束的位置。但预处理指令通常只用占一行，因此 不需要 分号结尾 4.打印字符串 printf() 函数 printf(\"Hello，World\\n\"); printf() 是一个功能十分强大的函数。 后面我们会进一步介绍 示例中我们只是用printf函数打印了出了一条 字符串字面量(string literal) —— 用一对双引号引起来的一系列字符。 字符串 ，顾名思义就是一串字符。 printf函数不会自动换行到下一行打印，它只会在它最开始那一行一直打印直到程序迫使它换行。 \\n 表示printf函数打印完成后跳转到下一行 请看如下程序，思考它的效果与示例中有何不同？ 答案 3 （点击或到文章尾查看） 如果想输出下面的结果，请考虑一下，应该如何写程序呢？ 答案： 对于这个问题，第二个printf函数的 \\n 可以省略。简单来说，printf函数会在 \\n 出现的地方换行。 5.注释 //a simple C program 写注释可以让自己和别人更容易明白你写的程序。 C语言注释的好处是：可以写在任何地方。注释的部分会被编译器忽略。 我们试想一件事你昨天吃了什么饭，记性好是吧？上周五吃的什么饭？如果连上周 一天三顿的饭都不能记住，何况你自己查看你很久之前写的代码呢？ 两种注释符号 第一种： /* */ 单行注释 多行注释 但是，上面这一种注释方式可能难以阅读，因为人不不容易发现注释结束的位置。 改进： 更好的方法：将注释部分围起来 当然如果你嫌麻烦，也可以简化一下： 简短的注释可以放在同一行 但是，如果你忘记了终止注释可能会导致你的编译器跳过程序的一部分，请思考下列： 你可以在自己的编译器上自己敲一下，看看会输出什么。 由于第一条注释忘记输入结束标志，导致编译器将直到找到结束标志之前的程序都当成了注释！ 第二种： // C99提供的新的注释方式。 新的注释风格有两大优点： 这种注释会在行末自动终结，所以不用担心会出现未终止的注释意外吞噬部分程序的情况 每行前都有 // ，所以多行的注释更加醒目 综上所述，建议采用 // 这种注释方式 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 网上资料：cppreference.com Footnotes 语法糖导致分号癌。摘自《epigrams-on-programming》 ↩ 为何不内置输入/输出? 原因之一是并非所有程序都会用到I/O（输入输出）包 。简洁高效表现了C语言的哲学。 ↩ 相同。 ↩"},{"title":"C语言基本结构（下）","slug":"c-modern-approach/03-basic-concepts-2","permalink":"/kb/posts/c-modern-approach/03-basic-concepts-2","category":"c-modern-approach","description":"Every program is a part of some other program and rarely fits.[^0]","date":"2026-06-16T00:00:00.000Z","content":"C语言基本结构（下） Every program is a part of some other program and rarely fits. 1 :globe_with_meridians:目录 [TOC] :apple:简单的程序结构 下面是一个简单的程序，身高是给出的，体重是在程序中得到的，我们输出的是体重与身高/体重的值。 这里我们更注重的是 程序的结构 而非程序本身。 示例 1. 类型 每一个变量都有 类型 （type）。类型用来描述变量的数据的种类，也称 数据类型 。 数值型变量的类型决定了变量所能存储的最大值与最小值，以及是否允许小数点后出现数字。 示例中只有一种数据类型： int int （integer）：即整型，表示整数。 数据类型还有很多，目前除了 int 以外，我们只再使用另一种： float （floating-point）: 浮点型，可以表示小数 注意 ：虽然 float 型可以带小数，但是进行 算术运算 时，float 型要比 int 型慢，而且 float 通常只是一个值的近似值。（比如在一个float 型变量中存储 0.1， 但其实可能这个变量的值为 0.09999987，这是舍入造成的误差） 题外话：我当时学的时候，就没有人告诉我这些知识，你们如果现在是初学，我都感觉到羡慕，你们要少走多少弯路啊！ 2. 关键字 int 与float 都是C语言的 关键字 （keyword）,关键字是语言定义的单词， 不能用做其他用途 。比如不能用作命名函数名与变量名。 关键字： 斜体代表C99新增关键字 auto enum unsigned break extern return void case float short volatile char for signed while const goto sizeof continue if static default struct do int switch double long typedef else register union restrict inline _Bool _Complex _Imaginary 如果关键字使用不当（关键字作为变量名），编译器会将其视为语法错误。 保留标识符（reserved identifier）：下划线开头的标识符和标准库函数名（如：printf()） C语言已经指定了这些标识符的用途或保留了它们的使用权，如果你使用它们作为变量名，即使没有语法错误，也不能随便使用。 3. 声明 声明 （declaration）：在使用变量（variable）之前，必须对其进行声明（为编译器所作的描述）。 声明的方式为：数据类型 + 变量名（程序员自己决定变量名，命名规则后面会讲） 示例中的 int weight 完成了两件事情。第一，函数中有个变量名为 weight。第二，int 表明这个变量是整型。 编译器用这些信息为变量 weight 在内存中分配空间。 C99 前，如果有声明，声明一定要在语句之前。（就像示例那样，函数体中第一块是声明，第二块才是语句。） C99 和 C11 遵循 C++ 的惯例，可以把声明放在任何位置。即可以使用时再声明变量。以后C程序中这种做法可能会很流行。 但是目前不建议这样。 就 书写格式 而言，我建议将声明全部放在 函数体头部 ，声明与语句之间 空出一行 。 4. 命名 weight,height 都是 标识符 ，也就是一个变量，函数或其他实体的名称。因此，声明将特定标识符与计算机内存的特定位置联系起来，同时也就确定了存储在某位置的信息类型或数据类型。 给变量命名时要使用有意义的变量名或标识符。如果变量名无法清楚的表达自身的用途，可以在注释中进一步说明，这是一种良好的编程习惯与编程技巧。 C99 与 C11 允许使用更长的标识符，但是编译器只识别前 63个字符。 对于外部标识符，只允许 31 个字符 。事实上，你可以使用更长的字符，但是编译器可能忽略超出的部分。（比如有两个标识符都是 64 个字符，但只有最后一个字符不同。编译器可能会视其为同一个名字，也可能不会。标准并未定义在这种情况下会发生什么。） 命名规则：可以用小写字母，大写字母，数字和下划线（_）来命名。 名称的第一个字符必须是字符或下划线，不能是数字 操作系统和C库经常使用一个下划线或两个下划线开始的标识符（如：_kcab），因此最好避免在自己的程序中使用这种名称。（避免与操作系统和c库的标识符重复） C语言的名称区分大小写。即：star，Star，STAR 是不同的。 声明变量的理由 ： 把所有变量放在一处，方便读者查找和理解程序的用途。 声明变量可以促使你在编写程序之前做好计划（比如你的程序要用什么变量，你可以提前规划）。 声明变量有助于发现程序中的小错误，如拼写错误。 不提前声明变量，C程序编译将无法通过 5. 赋值 赋值（assignment）：变量通过赋值的方式获得值。 示例中， weight = 160; 是一个 赋值表达式语句 。意思是“把值 160 赋给 变量 weight”。 在执行 int weight; 时，编译器在计算机内存中为变量 weight 预留的空间，然后在执行这行代码时，把值存储在之前预留的位置。可以给 weight 赋不同的值，这就是 weight 之所以被称为变量的原因。 注意： 该行表达式将值从右侧赋到左侧。 该语句以分号结尾。 = 在计算机中不是相等的意思，而是赋值。我们在读 weight = 160; 时，我们应该这么读：“将 160 赋给 weight” == 表示相等 6. printf() 函数 printf(“我的体重是：%d斤\\n，身高与体重的比为：%d”, weight, height / weight); 这是我们示例中的 printf 函数，我们来看两个不那么复杂的： 首先，printf() 的 圆括号 是不是很像 main() ？这表示 printf 是一个函数名，它也是一个函数。圆括号内的内容是从 main() 函数传递给 printf() 函数的信息。该信息被称为 参数 ，更确切的说，是 实际参数 （actual argument），简称 实参 。 既然是函数，它其实也是像我们看到的 main函数一样，也有函数头和函数体。 printf() 函数是一个库函数，库函数我们上一节讲函数种类时说过，这是一种不需要程序员去写的，只需要引用头文件 stdio.h 就可以直接使用的。但是我们应该知道这一点，详细情况我们后面会说讲。 当程序运行到 printf() 函数这一行时，控制权被转给了printf()函数。函数执行结束后，控制权被返回至主调函数（calling function），该例中是 main() 。 printf() 函数的作用是向我们的显示器输出内容。 此例中，printf() 函数的括号内 分为两部分，一部分在双引号内，另一部分在双引号外，它们中间以逗号隔开。双引号外有两个参数 weight 和 height / weight ，他们分别是变量和 表达式 （含有常量，变量和运算符的式子），也是指定要被打印的参数（打印到你的屏幕上）。 我们发现，最终我们屏幕上看到的是引号内的内容。我们可以来看一下输出的内容： 我们发现：首先引号内的 %d 和 \\n 并没有被输出， %d 的位置被替换成了一个整数。为什么会这样呢？ \\n 代表 一个换行符(newline character) 。对于 printf 函数来说，它的意思是：“ 在下一行的最左边开始新的一行 ”。 也就是说换行符和在键盘上按下 Enter按键相同。既然如此，为何不在键入 printf() 参数时直接使用 Enter键呢？因为编辑器可能认为这是直接的命令，而不是存储在源代码中的指令。换句话说，如果直接按下 Enter键，编辑器会退出当前行并开始新的一行。但是，换行符会影响程序输出的（显示）格式。 换行符是一个 转义序列 （escape sequence）。转义序列用于难以表示或无法输入的字符。如, \\t 代表 Tab键，即制表符。 \\b 代表 Backspace键，即退格键。我们在后面会继续讨论。 这样就解释了为何一行的printf() 函数会输出两行。 以下这部分不能理解可以只看结论，能理解更好。 在解释 %d 之前我们先来看一下，weight 和 height / weight 所代表的值。 weight 是被赋值为 160 的，所以 weight 的值就是 160 C语言中， / 表示除法， * 表示乘法。 那么 height / weight 的值是多少呢？我们现在不知道这个表达式的值是多少，但是我们知道这个它肯定代表 180 / 160 而最终输出的值是 1 ，这和我们想的不一样，我们知道结果应该是个小数，那么这是为什么呢？ 我想可能的原因有两个： %d 将小数转换为整数 180 / 160 本身在C语言中的值就是整数 我们来测试一下： 输出： 输出并不是我们想要的内容，我们来看一下编译器的警告： 编译器警告： 可以不去理解报错的内容。输出与报错至少说明了一点： %d 在我的编译器上无法输出浮点型；整型 / 整型 不是浮点型。 那就说明了原因2是对的，即： 180 / 160 的值就是 1 为什么 180 / 160 == 1 (180 / 160 的值是 1)呢？ 因为 weight 和 height 都整数，它们相除结果取整数（向下取整）。 如何输出 float 类型的浮点数？ %d 是一个占位符，其作用是指明 num 值的位置。d 代表 以十进制的格式。 还有一点要注意的是，在示例中，第二个输出的整数的参数（height / weight ）是一个表达式，我们也可以在程序中添加一个新的变量，然后用这个变量代替上面的表达式作为 printf() 的参数。如： 合理的使用表达式作为函数的参数可以简化程序。 也说明 在任何需要数值的地方，都可以使用具有相同类型的表达式 。 7. 初始化 当程序开始执行时，某些变量会被自动设置为0，而大多数不会。没有默认值并且尚未在程序中被赋值的变量时未初始化的（uninitialized）。 如果试图访问未初始化的变量，可能会得到不可预知的值。在某些编译器中，可能会发生更坏的情况（甚至程序崩溃）。 我们可以用赋值的办法给变量赋初值，但还有更简洁的做法：在变量声明中加入初始值。 例如示例中的 int height = 180 数值 180 就是一个 初始化式 （initializer）。 同一个声明中可以对任意数量的变量进行初始化。如： 上述每个变量都拥有属于自己的初始化式。接下来的例子，只有 c 有初始化式，a，b没有。 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 每个程序都是其他程序不合适的一部分。 ↩"},{"title":"格式化输入/输出","slug":"c-modern-approach/04-formatted-io","permalink":"/kb/posts/c-modern-approach/04-formatted-io","category":"c-modern-approach","description":"A programming language is low level when its programs require attention to the irrelevant.[^0]","date":"2026-06-16T00:00:00.000Z","content":"格式化输入/输出 A programming language is low level when its programs require attention to the irrelevant. 1 请将本片与下一节《数据类型》 联系起来一起“食用”。 注：本教程含有超纲内容！！！如果你看不懂，不要丧失信心，可以“不求甚解”一些，关键是要多写代码！然后继续学习下面的内容！ :arrow_forward: 此符号表示该内容以后的章节会讲解，此章节内不要求理解。 :globe_with_meridians:目录 [TOC] printf 函数 printf() 函数打印数据的指令要与待打印数据的类型相匹配。例如，打印整数时使用 %d ，打印字符时使用 %c 。这些符号被称为 转换说明 （conversion specification）,它们指定了如何把数据（以2进制形式）转换成可显示的形式。 例如： 这是 printf（）的格式： printf(格式字符串，待打印项1，待打印项2,...); 待打印项 都是要打印的的项。它们可以是 变量，常量 ，甚至是在打印之前计算的 表达式 。上例中，只有一个待打印项： 18 。 格式字符串 包含两种不同信息： 普通字符 ：以字符串中出现的形式打印出来。上例中，\"I am\" 与 \" years old\" 为普通字符 转换说明 ：用待打印项的值来替换。上例中，\"%d\" 为转换说明 :warning: C语言的编译器不会检测格式字符串中转换说明中的数量与待打印项总个数是否相匹配。 1.缺少参数 printf 会正确显示 i 的值，然后显示一个无意义的整数值。 2.参数过多 而在这种情况下，printf 函数会显示变量 i 的值，但是不会显示变量 j 的值 printf 转换说明 转换说明这部分我做了很久，比较详细，配合下一章 数据类型 才能看懂大部分，剩下的就需要你在不断使用的过程中领悟了。 标志 （可选，允许出现多于一个） - 字段内左对齐（默认右对齐） + 在打印的数前加上 + 或 - （通常只有负数前面附上减号） 例1 空格 在打印的非负数前前面加空格（ + 标志优先于空格标志） 例2 # 对象：八进制数，十六进制数，以g/G 转换输出的数 例3 0 用前导 0 在字段宽度内对输出进行填充。如果转换格式为d，i，o，u，x（X），而且指定了精度，可以忽略 0 例4 例 1： 123 -123 +123 -123 例 3： 例 4： 最小字段宽度 （可选） 如果数据项太小无法达到这个宽度，那么会对字段进行填充。（默认情况下会在数据项左侧添加空格，从而使字段宽度内右对齐）。 如果数据项过大以至于超过了这个宽度，那么会完整的显示数据项。 字段宽度可以是整数也可以是字符 * 。如果是字符 * ，那么字段宽度由下一个参数决定。如果这个参数为负，它会被视为前面带 - 标志的正数。 例5 例 5： 精度 （可选项） 如果转换说明是： d，i，o，u，x，X, 那么精度表示最少位数（如果位数不够，则添加前导 0 ） a，A，e，E，f，F ,那么精度表示小数点后的位数 g，G，那么精度表示有效数字个数 s，那么精度表示最大字节数 精度是由小数点（.）后跟一个整数或 * 字符构成的。如果是 * ，那么精度由下一个参数决定（如果这个参数为负，效果与不指定精度一样。）如果只有小数点，那么精度为0 。 例 6 例 6： printf(\"%.*d\\n\", -4, 123); 长度修饰符 （可选）。 长度修饰符表明待显示的数据项的长度 大于或小于 特定转换说明中的正常值。 例7 长度修饰符 转换说明符 含义 hh (C99) d，i，o，u，x，X signed char, unsigned char h d，i，o，u，x，X short, unsigned short l d，i，o，u，x，X long, unsigned long ll (C99) d，i，o，u，x，X long long, unsigned long long L a，A，e，E，f，F，g，G long double z (C99) d，i，o，u，x，X size_t j (C99) d，i，o，u，x，X ptrdiff_t 例 7： 0X22 0X1122 0XEEFF1122 0XEEFF1122 0XAABBCCDDEEFF1122 输出：为了方便大家观看我已经将输出中的换行删除了 printf() 返回值 返回值： 传输到输出流（显示器）的字符数 ，若出现输出错误或编码错误（对于字符串和字符转换指定符）则为 负值 。 返回类型： int 使用场景：检查输出错误。（看输出的字符数是否正确） 输出： 打印较长字符串 允许的换行方式： 错误的换行方式： 如果想在双引号括起来的格式字符串中换行，应该这样写： 方法1：使用多个 printf 语句 方法2：在要换行的地方加上反斜杠（ \\ ）来断行。但是，下一行的代码必须从该行最左端开始，不然输出会包含你所缩进的空白字符。 方法3：ANSI C 引入的字符串连接。C 编译器会将多个字符串看作一个字符串。 scanf() 函数 我们从键盘输入的都是文本，因为键盘只能生成文本字符：字符，数字和标点符号。如果要输入整数 2014，就要键入2，0，1，4.如果要将其存储为数值而不是字符串，程序就必须要把字符依次转换成数值，这就是 scanf() 要做的。 scanf() 把输入的字符串转换成整数，浮点数，字符和字符串，而 printf() 正好与之相反，把整数，浮点数，字符，字符串转换成显示在屏幕上的文本。 scanf() 与 printf() 类似，也要使用 格式字符串 和 参数列表。scanf() 中的格式字符串表明字符输入流的目标数据类型。两个函数的主要区别在于参数列表中。printf() 函数使用变量，常量和表达式，而 scanf() 函数使用指向变量的指针​(:arrow_forward:)。这里不需要了解指针，只需要记住一下简单的两条： 用 scanf 读取 基本变量类型的值，在变量名前加上一个 &#x26; 把字符串读入数组中，不要使用 &#x26; 下面的程序演示了这两条规则： input.c —— 何时使用 &#x26; :warning: 初学者在使用 scanf 时，在应该写 &#x26; 的时候容易忽略 &#x26; ，所以每次使用 scanf 的时候一定要格外小心。通常情况下，必要的地方缺少 &#x26; 会让程序崩溃（编译器没有警告），但是也有时候程序并不会崩溃，这时候找 bug 可能会让你头痛。 scanf 的 长度修饰符 和 转换说明符 与 printf 几乎相同。主要的区别如下： 长度修饰符 ：（可选项）对于 float 与 double 类型，printf() 的转换说明都用 f ; 而对于 scanf() ，float 保持不变，double 要在 f 前加长度修饰符 l ，即： lf 。 例 1 例 1： 转换说明符 ： %[集合] 匹配集合中的任意序列； %[^集合] 匹配非集合中的任意序列。 例 2 例 2： 字符 * ：（可选项）字符 * 出现意味着 赋值屏蔽 （assignment suppression）: 读入此数据项，但是不会将其赋值给对象。用 * 匹配的数据项不包含在 ...scanf 函数返回的计数中。 例 3 例 3： 最大字段宽度 ：（可选项）最大字段宽度限制了输入项中的字符数量。如果达到最大值，那么次数据项的转换结束。转换开始跳过的空白不计。 例 4 进一步思探究 scanf() 在上面了解了 scanf 的基本情况后，我们进一步探究 scanf 函数。 上面的例 2，为何只是输出了 \"123\", 我们明明还输入了一组 123，为什么没有输出呢？ scanf 函数如果发生了 输入失败 （没有字符输入）或 匹配失败 （即输入字符和格式串不匹配），那么...scanf 会提前返回。返回就意味着这个 scanf 的读入结束。 scanf 返回的又是什么呢？ 成功赋值的接收参数的数量（可以为零，在首个接收用参数赋值前匹配失败的情况下），或者若输入在首个接收用参数赋值前发生失败，则为EOF(EOF 的值是 -1)。 在C程序中测试 scanf 函数的返回值的循环很普遍。例如，下面的循环逐一读取一串整数，在首个遇到问题的符号处停止： 对于 scanf 部分最开始的程序 input.c 如果我们这样先输入： 再这样输入： 如果你添加上 printf 语句输出这三项，会发现，这两种输入的输出是一样的。 在寻找起始位置时，scanf 函数会忽略空白字符 （white-space character，包括空格符，水平和垂直制表符，换页符和换行符)，但是 %[ , %c, %n 除外。 例 5 例 5： 这个例子除了证明了上面的结论，还说明了： 但是 scanf 函数会忽略最后的换行符 ，实际上它没有读取它，这个换行符时下一次 scanf 函数读入的第一个字符。 scanf 函数遵循什么规则来识别整数或浮点数呢？ 在要读入整数时，scanf 函数首先会寻找正号或负号，然后从读入一个数字开始直到读入一个非数字为止。 当要求读入浮点数时，scanf 函数首先会寻找正号或负号（可选），然后是一串数字（可能含有小数点），再后是一个指数（可选）。指数由一个字母e，可选的符号，一个或多个数字组成。 当 scanf 函数遇到一个不可能输入当前项的字符时，它会把此字符“放回原处” ，以便在扫描下一项或下一次调用 scanf 时再次读入。思考下面（公认有问题的）4个数的排列： 然后我们用这个 scanf 函数来读入： scanf 会如何处理这组输入呢？ %d ：读入 1 %d ：读入 -20 %f ：读入 .3 (当作 0.3 处理) %f：读入 剩下的输入。但是不读入最后的回车 使用 %s 转换说明 ，scanf 会读取除了空白字符以外的所以字符。scanf 跳过空白字符并开始读入第一个非空白字符，保存非空白字符直到再遇到空白字符结束。这意味着，scanf 最多只能读取一个单词。无法利用字段宽度使得 scanf 读取多个单词，scanf 会在字段宽度结束或遇到空白字符处停止。scanf 将字符串放入数组时，会在字符串序列末尾加上一个 \\0 。 格式串中的普通字符 空白字符 ：...scanf 函数格式串中的一个或多个连续的空白字符与输入流中的零个或多个空白字符匹配。 简单说一下就是，格式串中有空格，输入时你可以不写空格或写多个；格式串中有多个空格，输入时你可以只写一个空格。 非空白字符 ：看个程序就明白了： 空格你可以随便空，换行都可以随便换，但是一定要打 ''-'' 符号。 易混淆的 printf() 与 scanf() 输出的并不是 i 的值 （而是 i 的地址的十进制数值） scanf 在第一个 %d 读入一个整数后，试图把逗号与输入流中的下一个字符相匹配，如果这个字符不是 , ,那 scanf 就会终操作，而不再读取变量 j 的值。 printf 函数中经常有 \\n ，但是如果在 scanf 格式串结尾放一个 \\n 通常会引发你预期之外的问题。 对于 scanf 函数来说，\\n 等同于空格，那么 scanf 就会在流中寻找空白字符，但是我们上面说过，scanf 格式串中的空白字符会与 输入流中的零个或多个空白字符匹配。所以当你输入完成后按下回车，这个回车会与 scanf 中的 \\n 匹配，之后你无论打多少回车都不会使 scanf 结束，除非你输入一个非空字符，使 scanf 因匹配失败而退出。 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 任何编程语言在处理无关事务时都是低级语言。 ↩"},{"title":"数据类型","slug":"c-modern-approach/05-basic-types","permalink":"/kb/posts/c-modern-approach/05-basic-types","category":"c-modern-approach","description":"If a program manipulates a large amount of data, it does so in a small number of ways.[^0]","date":"2026-06-16T00:00:00.000Z","content":"数据类型 If a program manipulates a large amount of data, it does so in a small number of ways. 1 目录 [TOC] :banana:概述 关键字 C语言的数据类型关键字 最初 K&#x26;R 给出的关键字 C90标准添加的关键字 C99标准添加的关键字 int signed _Bool （布尔型） short void _Complex（复数） long _Imaginary(虚数) unsigned char float double 通过这些关键字创建的类型，按计算机的存储方式可分为两大基本类型： 整数类型 和 浮点数类型 位，字节和字 位，字节和字 **位（bit）：**最小的存储单元，也称比特位。可以存储 0 或 1（或者说，位用于存储“开”或“关”） **字节（byte）：**1 byte = 8 bit 既然 1 位可以表示 0 或 1，那么 1 字节就有 256 （2^8）种 0/1 组合，通过二进制编码（仅用 0/1 便表示数字），便可表示 0 ~ 255 的整数或一组字符。（ 以后会详细讲解 ） **字（word）：**是设计计算机时给定的自然存储单位。对于 8 位 的微型计算机（如：最初的苹果机），1 字长 只有 8 位，从那以后，个人计算机的字长增至 16 位，32位，直至目前的 64位。计算机字长越大，其数据转移越快，允许访问的内存越多。 整数 整数 7 以二进制形式表示是：111 ，用一个字节存储可表示为： 浮点数 浮点数相比我们都不陌生，本节后面还会做更详细的介绍。现在我们介绍一种浮点数的表示方法：e记数法。 如 3.16E+007 表示 3.16 * 10^7(3.16乘以10的七次方)。007 表示 10^7；+ 表示 10 的指数 7 为正数。 其中，E 可以写成 e；表示正次数时，+ 号可以省略；007也可以省略为7。即：3.16e7。 浮点数和整数的存储方案是不同的。计算机将浮点数分成小数部分和指数部分来表示，而且分开存储这两部分。因此，虽然 7.0 和 7 在数值上相同，但它们的存储方式不同。下图演示了一个存储浮点数的例子。 后面我们会做更详细的解释 整数与浮点数的区别： 整数没有小数部分，浮点数有小数部分 浮点数可以表示的范围比整数大 对于一些算术运算（如，两个很大的数相减），浮点数损失的精度更多 因为在任何区间内都存在无穷多个实数，所以计算机的浮点数不能表示区间内的所有值。浮点数通常只是实际值的近似值。（例如，7.0 可能被存储为浮点值 6.99999） 过去，浮点数运算比整数运算慢。不过现在许多CPU都包含了浮点数处理器，缩小了速度上的差距。 整数类型 有符号整数和无符号整数 有符号整数 如果为 零或正数 ，那么最左边的位（符号位，只表示符号，不表示数值）为 0 ；如果为 负数 ，则符号位为 1 。如：最大的 16 位整数（2个字节）的二进制表示形式是 01111111 11111111，对应的数值是 32767（即：2^15 - 1） 无符号整数 不带符号位（最左边的位是数值的一部分）。因此，最大的 16 位整数的二进制表示形式是：11111111 11111111（即：2^16 - 1） 默认情况下，C语言中的整型变量都是有符号的，也就是说最左位保留符号位。若要告诉编译器变量没有符号位，需要把他声明成 unsigned 类型。 整数的类型 short int unsigned short int int unsigned int long int unsigned long int 整数的类型归根结底只有这 6 种，其他组合都是上述某种类型的同义词。 例如：long signed int 与 long int 是一样的；unsigned short int 与 short unsigned int 是一样的 C语言允许通过省略单词 int 来缩写整数类型的名称。 例如：unsigned short int 可以缩写为 unsigned short ；而 long int 可以缩写为 long。 C程序员经常省略 int 。 6 种 整数类型每一种所表示的取值范围都会根据机器的不同而不同，但是有两条所有编译器都必须遵守的原则。 C 标准要求 short，int，long 中的每一种类型都要覆盖一个确定的最小取值范围（ 后面会详细讲解 ） int 类型不能比 short 类型短，long 类型不能比 int 类型短 这也就是说：short 的大小可以与 int 相等；int 的大小可以与 long 相等 16位，32位，64位机器的整数类型都各有些不同，我们常用的是 32 位机器（严格来说是编译器，我的电脑是 64 位，但是VS2019用的最多的是 32位模式），我们就以 32 位机器为例 32位机器整数类型 类型 最小值 最大值 short -32768( - 2^15 ) 32767(2^15 -1 ) unsigned short 0 65535 (2^16 - 1) int - 2147483648(- 2^31) 2147483647(2^31 - 1) unsigned int 0 4294967295 long - 2147483648 2147483647 unsigned long 0 4294967295 可以看出，32位机器上，int 与 long的大小是一样的，都是 4 个字节。 16位机器上，int 与 short 大小是一样的，都是 2 个字节。 64位机器上，与 32 位机器不同的是，long 是 8 个字节。 但是，上述所说的规律并不是 C标准规定的，会随着编译器的不同而不同。可以检查头文件 &#x3C;limits.h> ，来查看每种整数类型的最大值和最小值。（下面给出我的VS2019的 limits.h 头文件） limits.h C99 中的整数类型 C99 提供了两个额外的整数类型： long long int 和 unsigned long long int 整数常量 整数常量：在程序中以文本形式出现的数，而不是读，或计算出来的数。 C语言允许用 十进制 （基数为 10）， 八进制 （基数为 8）， 十六进制 （基数为 16）的形式书写整数常量 8 进制 与 16 进制 8 进制数是用数字 0 ~ 7 书写的。八进制的每一位表示一个 8 的幂（这就如同 10 进制每一位表示 10 的幂一样）。因此，八进制数 237 表示成 10 进制数就是 2 * 8^2 + 3 * 8^1 + 7 * 8^0 = 128 + 24 + 7 = 159 16 进制数使用数字 0 ~ 9 加上字符 A ~ F 书写的，其中字符 A ~ F 表示 10 ~ 15 的数。16进制数每一位表示一个 16 的幂，16进制数 1AF 的十进制数值是 1 x 16^2 + 10 * 16^1 + 15 * 16^0 = 256 + 160 + 15 = 431 如果上面的描述你还是没有懂，可以参考下图： 十进制 常量包含 0 ~ 9 的数字，但是不能以 0 开头 15 255 32767 八进制 常量包含 0 ~ 7 的数字，必须要以 0 开头 017 0377 077777 十六进制 常量包含 0 ~ 9 的数字 和 A ~ F 的字母，总是以 0x 开头 0xf 0xff 0x7fff 十六进制常量中的字母可以是大写也可以是小写 输出： 八进制与十六进制只是书写数的方式，他们不会对数的实际存储方式产生影响 （ 整数都是以二进制形式存储的 ）。任何时候都可以从一种书写方式切换的另一种，甚至可以混合使用：10 + 015 + 0x20 = 55 。八进制和十六进制更适合底层程序的编写（以后会详细讲到）。 十进制 整数常量的类型通常是 int ，但如果常量过大，就用 long int 类型，如果还不够用，编译器会用 unsigned long int 做最后尝试。 八进制和十六进制 常量编译器会依次尝试：int，unsigned int，long int 和 unsigned long int 类型，知道找到能表示该常量的类型。 为了强制编译器把常量作为长整数来处理，只需要在后面加上一个字母L（或l，字母l比较像数字1所以建议大写）： 15L 0377L 0x7ffffL 为了指明是无符号常量，可以在常量后面加上字母U（或u）： 15U 0377U 0x7ffffU L 与 U 可以结合使用：0xffffffffLU（L 与 U 的书写顺序无所谓） C99 中的整数常量 在 C99 中，以 LL 或 ll （字母大小写要一致）结尾的整数常量是 long long int 类型。在 LL 或 ll 前面或后面加上 U（u）表示 unsigned long long int 类型。 C99 与 C89 在确定整数常量类型规则上有些不同。 对于 没有后缀的十进制常量 ，其类型是 int ，long int，long long int 中能表示该值的 最小 类型。 对于 八进制和十六进制常量 ，可能的类型顺序为：int，unsigned int，long int，unsigned long int，long long int,unsigned long long int。 常量后面任何后缀都会改变可能的类型列表。 整数溢出 对整数执行算数运算时，其结果可能太大而无法表示。 例如，对两个 int 值进行算数运算时，其结果必须仍然能用 int 来表示；否则（表示结果所需要的数位（二进制）太多），就会发生 溢出 。 有符号整数 的溢出时，程序的行为时未定义的。未定义行为的结果是不确定的。最有可能的结果是，仅仅是运算出错，但是程序也有可能崩溃，或者出现其他意想不到的情况。 无符号整数 溢出时，结果是有定义的：对 2^n 取模，其中 n 是用于存储结果的位数。例如：如果对无符的 16 位数 65535 加 1，其结果可以保证为 0 。 请看下面的程序，也许可以帮助你理解。 tobig.c —— 超出系统最大 int 值 输出： 可以将无符号整型 j 看作是汽车的里程表。当达到他能表示的最大值时，会重新从起点开始。整数 i 也是类似的情况。它们的主要区别是，在超过最大值时， unsigned int 类型的变量 j 从 0 开始；而 int 型的变量则从 -2147483648 开始。注意，当 i 超出（溢出）其相对类型所能表示的最大值时，系统并未通知用户。因此必须自己注意这类问题。 读/写整数 读写 无符号整数 ： unsigned int a ; 十进制 ： scanf(\"%u\", &#x26;a); printf(\"%u\", a); 八进制 scanf(\"%o\", &#x26;a); printf(\"%o\", a); 十六进制 scanf(\"%x\", &#x26;a); printf(\"%x\", a); 读写**短整型*数：在 d，u，o，x 前加上 h short b scanf(\"%hd\", &#x26;b); printf(\"%hd\", b); 读写 长整数 ：在 d，u，o，x 前加上 l long c scanf(\"%ld\", &#x26;c); printf(\"%ld\", c); 读写 长长整数 : 在 d，u，o，x 前加上 ll long long int d scanf(\"%lld\", &#x26;d); printf(\"%lld\", d); 改进程序 观察上述程序，请思考：两个 int 型的变量的和可能超过 int型变量允许的最大值。因此，为了改进这个程序，我们可以将 int 型的 a，b，sum 都变为 long long 型（考虑到 32 位机器的 long 与 int 大小是相同的。）如下： 浮点类型 C语言提供了三种浮点类型，对应着不同的浮点格式： "},{"title":"表达式","slug":"c-modern-approach/06-expressions","permalink":"/kb/posts/c-modern-approach/06-expressions","category":"c-modern-approach","description":"C语言表达式的求值规则、运算符优先级与结合性","date":"2026-06-16T00:00:00.000Z","content":"表达式 Symmetry is a complexity reducing concept (co-routines include sub-routines); seek it everywhere. 1 目录 [TOC] 一 算术运算符 1.概念 一元运算符（只需要 1 个操作数） + 一元正号运算符 - 一元负号运算符 ​ 二元运算符 加法类 乘法类 + 加法运算符 * 乘法运算符 - 减法运算符 / 除法运算符 % 求余运算符 注意： int 型与 float 型混合在一起时，运算结果是 float 型。 比如，9 + 2.5f 的值为 11.5；6.7f / 2 的值为 3.35。 运算符 / ：当两个操作数都是整型时，结果会 向下取整 。如，1 / 2 的值是 0，而不是 0.5 。 运算符 % 要求两个操作数都是 整型 。 把 0 作为 / 或 % 的右操作数会导致未定义行为。 当运算符 / 和 % 用于负操作数时，其结果难以确定。 根据 C89 的标准，如果两个操作数中有一个是负数，那么除法结果 既可以向上取整也可以向下取整 （例如，-9 / 7 的结果既可以是 -1 也可以是 -2）；i % j 的符号与具体实现有关（例如，-9 % 7 可以是 -2 也可以是 5）。 在 C99 中，除法的结果总是 向零取整 （因此，-9 / 7 的结果是 -1）；i % j 的符号与 i 相同（因此，-9 % 7 的结果是 -2；我特意测试了以下，9 % -7 的值是 2，-9 % -7 的值还是 2）。 **“由实现定义”**的行为： 术语由实现定义（implementation-defined)指的是 C标准对 C语言的部分内容未加指定，并认为其细节可有“实现”来具体定义。所谓实现是指程序在特定平台上编译，链接和执行所需要的软件。因此，根据实现的不同，程序的行为可能稍微有差异。 这样做的可能很奇怪甚至危险。但是这正是 C语言的目标之一——高效，这常常意味着与硬件相匹配。 对于我们来说，我们要 尽量避免编写这种由实现定义的行为的程序 。如果不能做到，起码要仔细查阅手册。 2. 运算符的优先级和结合性 当表达式包含多个运算符时，其含义可能不是一目了然的。我们的解决方法是： 用括号进行分组 了解运算符的优先级和结合性 运算符优先级 （operator precedence） 最高优先级 + - （一元运算符） * / % 最低优先级 + - （二元运算符） 例 1-1： 运算符的结合性 当表达式包含两个或更多相同优先级的运算符时，仅有运算符优先级规则是不够的。这种情况下，运算符的 结合性 （associativity）开始发挥作用。 如果运算符是从左向右开始结合的，那么称这种运算符是左结合的。 二元运算符即： *,/,%,+,- 都是左结合的。所以： 例 1-2: 运算符是右结合的，如一元运算符： +,- 。 例 1-3： 3.总结 在许多语言（特别是 C 语言）中，优先级和结合性规则都是十分重要的。然而 C 语言的运算符太多了（差不多 50 种）。 为了自己和他人理解代码的方便，请最好加上足够多的圆括号。 二 赋值运算符 求出表达式的值后往往需要将其存储在变量中，以便将来使用。C语言的 = （简单赋值 simple assignment）运算符可以用于此目的。为了更新已经存储在变量中的值，C语言还提供了一种复合赋值（compound assignment）。 1. 简单赋值 表达式 v = e 的赋值效果是求出表达式 e 的值，然后将此值赋值给 v。 例 2-1： 如果 v 与 e 的类型不同，那么赋值运算发生时会将 e 的值转化为 v 的类型： 例 2-2： 在很多编程语言中，赋值是 语句 ；然而在 C语言中，赋值就像 + 那样是 运算符 。 既然赋值是运算符，那么多个赋值语句可以串联在一起： 例 2-3： 运算符 = 是右结合的 ，所以，上面的语句等价于： 作用是先将 0 赋值给 m，再将 m 赋值给 k，再将 k 赋值给 j，再将 j 赋值给 i 。 ​ ! 注意 因为赋值运算符存在 类型转换 （本节后面会讲），串在一起赋值运算的结果可能不是预期的结果： 2. 左值 赋值运算要求它的左操作数必须是 左值 （lvalue）。左值表示在计算机中的存储对象，而不是常量或计算的结果。 左值是变量。 例 2-4： 以上三种表达式都是错误的。 3. 复合赋值 上面的例子中 += 就是一种符合运算符，表示：将自身表示的数增加 2 后再赋值给自己。 与加法相似，所有赋值运算符的工作原理大体相同。 += -= *= /= %= 注意： i *= j + k 和 i = i * j + k 是不一样的。 使用复合赋值运算符时，注意不要交换组成运算符的两个字符的位置。如： i += j 写成了 i =+ j 后者等价于： i = (+j) 复合运算符有着和 = 运算符一样的特性。它们也是右结合的，所以： i += j += k 等价于 i += (j += k) 4. 自增运算符和自减运算符 ++ -- “自增”（加1）和“自减”（减1）也可以通过下面的方式完成： 复合赋值运算符可以简化上面的语句： 而 C语言 允许用 ++ 和 -- 运算符将这些语句缩的更短。比如： 或者： 这两种形式的写法的意义不同的： ++i （前缀（prefix）自增），意味着“立即自增 i ” i++ （后缀（postfix）自增），意味着“先使用 i 的原始值，稍后再自增”。稍后是多久？C语言标准没有给出精确的时间，但是可以放心的假设 i 再下一条语句执行之前进行自增。 -- 运算符具有相同的特性。 后缀的 ++ 和 -- 比一元的正号，负号优先级高，而且都是左结合的。 前缀的 ++ 和 -- 与一元的正号，负号优先级相同，并且是右结合的。 比如： 5.表达式求值 部分C语言运算符表 优先级 类型名称 符号 结合性 1 （后缀）自增 ++ 左结合 （后缀）自减 -- 2 （前缀）自增 ++ 右结合 （前缀）自减 -- 一元正号 + 一元符号 - 3 乘法类 * / % 左结合 4 加法类 + - 左结合 5 赋值 = *= /= -= += 右结合 能理解下面这个表达式的意义，就算掌握了这一部分的表达式求值规则： 等价于： 子表达式的求值顺序 C语言没有定义子表达式的求值顺序（除了含有 逻辑与，逻辑或 或 逗号运算符的表达式（后面会讲））。 但是不管子表达式的计算顺序如何，大多数表达式都有相同的值。但是，当子表达式改变了某个操作数的值时，产生的值就可能不一致了。思考下面的例子： 第二条语句的执行结果是未定义的。对大多数编译器而言，c 的值是 6 或者 2。取决于 子表达式 b = a + 2 和 a = 1 的求值顺序。 像上例那样， 在表达式中，既在某处访问变量的值，又在别处修改它的值是不可取的。 为了避免出现此类情况，我们可以将子表达式分离： 执行完这些语句后，c 的值将始终是 6 除此之外，自增自减运算符也要小心使用。如下例： j 有两种可能：4 或 6 我们很自然的认为结果是 4 。但是其实该语句的执行结果是未定义的。 j 的值为 6 的情况： 取出第二个操作数（i 的原始值），然后 i 自增 取出第一个操作数（i 的新值） 将取除的两个操作数相乘（2 和 3），结果是 6 “取出”变量意味着从内存中获取它们的值。变量后续变化不会影响已经取出的值，因为取出的值通常存储在 CPU 中称为 寄存器 的一个特殊位置。 未定义行为 未定义行为（undefined behavior）: 类似上面两个例子中的语句会导致 未定义行为，这和我们前面讲的 由实现定义 的行为是不同的。当程序中出现未定义行为时，后果是不可预料的。不同的编译器给出的结果可能是不同的。也就是说，程序可能无法通过编译，也可能运行时崩溃，不稳定或者产生无意义的结果。 换句话说，我们应该像躲避“新冠”一样避免未定义行为 。 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 对称性有助于减少复杂度（协程包含例程）。对称性无处不在。 Epigrams on Programming 编程警句 ↩"},{"title":"选择语句","slug":"c-modern-approach/07-selection-statements","permalink":"/kb/posts/c-modern-approach/07-selection-statements","category":"c-modern-approach","description":"It is easier to write an incorrect program than understand a correct one. [^1]","date":"2026-06-16T00:00:00.000Z","content":"选择语句 It is easier to write an incorrect program than understand a correct one. 1 目录 [TOC] 选择语句 前面已经讲过C语言的语句主要分为 6 大类。本节我们主要探讨 选择语句：if 语句 和 switch 语句 一 逻辑表达式 包括 if 语句在内的某些 C 语句（while，for 等）都必须测试表达式的值是“真”还是“假”。 许多编程语言中，类似 i &#x3C; j 这样的表达式都具有特殊的“布尔”类型或者“逻辑”类型（C++ 的 bool 和 Java 的 boolean）。这样的类型只有两个值，即真（true）和假（false）。 而在 C 语言中，诸如 i &#x3C; j 这样的比较会产生整数：0（假）1（真）。 但是，非 0 的其他数也可以表示 真 。在今天看来，这是 C 语言设计的弊端，它将布尔类型与整型混为一谈，让我们在变成过程中可能稍不小心就会给自己挖一个坑。 1. 关系运算符 C 语言的关系运算符（relational operator）和数学上的 >,&#x3C;,≤，≥ 相对应，只是用在 C 语言的表达式中时产生的结果是 0 或 1 。 例如，表达式 10 &#x3C; 11 的值是 1，11 &#x3C; 10 的值是 0 。 关系运算符也可以用于比较整数和浮点数，也允许比较不同类型的操作数。如：5.6 &#x3C; 5 的值为 0 。 符号 含义 &#x3C; 小于 > 大于 &#x3C;= 小于等于 >= 大于等于 关系运算符的优先级 低于 算数运算符。例如： i + j &#x3C; k - 1 的意思是 (i + j) &#x3C; (k - 1) 关系运算符都是 左结合 的。 注意： 表达式 i &#x3C; j &#x3C; k 在 C 语言中是合法的，但是可能不是你所期望的含义。因为 &#x3C; 运算符是左结合的，所以这个表达式等价于： (i &#x3C; j) &#x3C; k 表达式会先检测 i 是否小于 j，然后用比较后产生的结果（1 或 0 ）来和 k 进行比较。这个表达式并不是测试 j 是否位于 i 和 k 之间。（正确的写法是： j > i &#x26;&#x26; j &#x3C; k ） 2. 判等运算符 **判等运算符(equality operator)：**相等用 == 表示 。注意不是 = ， = 表示赋值。 注意： 一定要注意不要将 == 写成 = ，编译器可能会给你报错，但是如果没有，在你查错的时候，注意是不是 == 写错了的问题。 符号 含义 == 等于 != 不等于 和关系运算符一样，判等运算符是 左结合 的，也是产生 0（假） 或 1（真） 作为结果。 判等运算符的优先级 低于 关系运算符。例如： i &#x3C; j == j &#x3C; k 等价于： (i &#x3C; j) == (j &#x3C; k) ，含义是：如果 i &#x3C; j 和 j &#x3C; k 同真或同假 这个表达式的结果为真。 利用 关系运算符 和 判等运算符 ： 上面表达式的值为 0，1 或者 2 分别代表 i &#x3C; j, i > j, i == j 3. 逻辑运算符 逻辑运算符（logical operator） 符号 含义 ! 逻辑非（一元运算符） &#x26;&#x26; 逻辑与（二元运算符） || 逻辑或（二元运算符） 其实 &#x26;&#x26; 就是 数学中的 且 ，|| 就是数学的的 或 逻辑运算符产生的结果仍然是 0 或 1，操作数经常也是 0 或 1，但这不是必需的。逻辑运算符将任何 非0 值操作数当作真来处理，任何 0 值操作数当作假来处理。 如果表达式的值为 0，那么 ！表达式 的结果为 1 如果 表达式1 和 表达式2 的值都是非零值，那么 表达式1 &#x26;&#x26; 表达式2 的结果为 1 如果 表达式1 和 表达式2 的值 中的任意一个是（或者两个都是）非零值，那么 表达式1 || 表达式2 的结果为 1 所有其他情况下，这些运算符产生的结果都为 0 “短路”计算 &#x26;&#x26; 和 || 运算符都遵循“短路”原则。也就是说，这些运算符首先计算出左操作数的值，然后计算右操作数； 如果表达式的值可以仅由左操作数推导出来，那么将不计算右操作数的值 。如： 运算符 ！的优先级和一元正负号优先级 相同 ，运算符 &#x26;&#x26; 和 || 的优先级 低于 判等运算符。 例如： i &#x3C; j &#x26;&#x26; k == m 等价于 (i &#x3C; j) &#x26;&#x26; (k == m) 运算符 ! 是 右结合 的，&#x26;&#x26; 和 || 是 左结合 的。 二 if 语句 1. if if 语句允许程序通过测试表达式的值从两种选项中选择一种。if 语句的简单格式如下： 如果语句部分只有一条语句，也可以写成 执行 if 语句时，先计算圆括号内表达式的值。如果表达式的值 非零 （C语言将非零值解释为真值），那么接着执行大括号内的语句。例如： 为了判定 k &#x3C; i &#x3C; j，可以这样写： 为了判定相反的情况，可以写成： **例2-1：**程序：为了判定一个数是不是大于零的，如果是，我们就输出提示语，然后让这个数加 1 2. else 子句 如果是 复合语句 （compound statement），需要加上花括号 加上花括号是一种好习惯。建议不管是不是复合语句，尽量都加上花括号。 例2-2 ：增加需求：如果这个数不是正数，那么输出提示语，然后让这个数减 1 3. 嵌套的 if 语句 例2-3 ：找出 i，j，k 中的最大值，并将其保存到 max 中 4. 级联式 if 语句 编程时常常需要判定一系列的条件，一旦其中某个条件为真就立刻停止。 如何做到呢？ 例2-4 程序：判断 n 是大于 0 还是 等于 0 还是小于 0 使用 if else 使用 else if 这样写可以避免 if else 嵌套，从而提高了书写和理解的难易度。 级联式 if 语句书写形式： 5. “悬空 else”问题 请看下面的程序，思考 else 与 那个 if 匹配 如果此时 y = 0, x = 2 会输出什么？ 如果 y = 2, x = 0 会输出什么？ 虽然缩进格式按时 else 属于外层 if，但是 C 语言遵循的规则是 else 子句应该属于离它最近且还未和其他 else 匹配的 if 语句 。 所以，此例中 else 属于内层的 if 语句。为了避免这种问题，最好的办法就是 加括号 。 6. 条件表达式 **条件运算符(conditional operator)：**C 语言运算符中唯一一个三元(ternary)（三个操作数）运算符。 格式： 例2-6 上面的程序我们用条件运算符可以这么写： 判断 k 的值 条件运算符使程序 更短小但也更难以阅读 ，所以最好避免使用。然而有的情况会常常使用条件表达式。比如： 1）判断返回： 2）printf 条件表达式也普遍应用于某些类型的宏定义中。 7. 布尔值 C89 多年以来，C语言一直缺乏适当的布尔类型。 一种解决方法是，先声明一个 int 型变量，让后将其赋值为 0 或 1： 虽然这种方法可行，但是对于程序的可读性没有多大贡献，因为没有明确的表示 flag 的赋值只能是布尔型，并没有明确指出 0 和 1就是表示真和假。 为了使得程序更加便于理解，C89 程序员通常使用 TRUE 和 FALSE 这样的名字定义宏： 现在对 flag 的赋值就有了更加自然的形式： 为了判定 flag 是否为真，可以用： 或者只写： 为了发扬这一思想，我们可以进一步定义一个用作布尔类型的宏： 声明布尔类型变量时可以用 BOOL 替代 int 现在我们就非常清楚了：flag 不是一个普通的整型变量，而是表示布尔条件。（当然编译器还是将 flag 当作 int 类型的变量。） C99 C99 中提供了 _Bool 类型： _Bool 是无符号整型。但是和一般的整形不同，_Bool 只能被赋值为 0 或 1 。一般来说，向 _Bool 类型变量中储存非零值会导致变量赋值为 1 。 C99 还提供了一个新的头文件 &#x3C;stdbool.h> ，改头提供了 bool 宏，用来代表 _Bool ；还提供了 true 和 false 两个宏，分别代表 1 和 0 。于是可以写： 三 switch 语句 日常的编程中，常常需要把表达式和一系列值进行比较，从而找出当前匹配的值。 使用级联式 if 语句可以达到这个目的： C 语言提供了 switch 语句作为这类级联式 if 语句的替换。使用 switch 语句改写上面的程序： switch 语句常用格式： **控制表达式：**控制表达式只能用：整型，字符型的变量（C 语言把字符当成整数来处理），不能用浮点数 和 字符串。 **分支标号：**每一个分支的开头都有一个标号，格式如下： case 常量表达式； 常量表达式(constant expression): 必须是 整数或字符型 ，不能包含 变量和函数调用 。 5 是常量表达式，5 + 10 也是常量表达式；但是 10 + n 不是常量表达式（除非 n 是表示常量的宏）。 **语句：**每个分支标号后可以跟任意数量的语句。不需要用花括号把这些语句括起来。每组语句的最后一条通常是 break 语句。 break 的作用： 本节后面会详细讨论。 default 语句的作用： 控制表达式的值和所有的标号语句都不匹配的话，会执行 default 后面的语句。（default ：默认的意思） C 语言 不允许有重复的分支标号，但对分支的顺序没有要求 ，特别是 default 分支不一定要放在最后。 case 后 只可以跟随一个常量表达式 。但是， 多个分支标号可以放置在同一组语句前面 。如： 为了节省空间，可以将拥有相同语句的分支标号放在同一行： switch 语句 不要求一定有 default 分支 。如果 default 不存在，而且控制表达式的值和所有的标号语句都不匹配的话，控制会直接传给 switch 语句后面的语句。 break 语句的作用 break 会使程序“跳出” switch 语句，继续执行 switch 后面的语句。 对控制表达式求值的时，控制会跳转到与 switch 表达式相匹配的分支标号处。分支标号只是说明 switch 内部位置的标记。在执行完分支的最后一句后，程序控制“向下跳转”到下一个分支的第一条语句上，而忽略下一个分支的分支标号。如果没有 break 语句（或者其他某种跳转语句），控制将会从一个分支继续到下一个分支。思考下面的 switch 语句： 如果 grade 的值为 3，那么显示的信息是： **注意：**忘记 break 是编程时常见的错误。虽然有时候会故意忽略 break 以便多个分支共享代码，但是通常情况下省略 break 是因为忽略。 如果有需要， 明确指出 故意省略 break 语句是一个好主意： 最然 switch 语句最后一个分支不需要 break 语句，但是通常还是会加上一个 break 语句在那里， 以防止将来增加分支时出现“丢失” break 的问题 。 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 写错误的程序比理解正确的程序简单。 Epigrams on Programming 编程警句 ↩"},{"title":"循环","slug":"c-modern-approach/08-loops","permalink":"/kb/posts/c-modern-approach/08-loops","category":"c-modern-approach","description":"C语言循环语句：while、do-while、for及嵌套循环","date":"2026-06-16T00:00:00.000Z","content":"循环 It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures. 1 目录 [TOC] 循环 本章将介绍 C 语言的 重复语句（迭代语句） ，这种语句允许用户设置循环。 循环 （loop）时重复的执行其他语句（ 循环体 ）的一种语句。在 C 语言中，每个循环都有自己的 控制表达式 （controlling expression）。循环每执行一次，都要对控制表达式求值。如果表达式为真（表达式的值非 0），那么循环继续执行；否则，退出循环。 C 提供了三种迭代语句： while 语句 do 语句（do while 语句） for 语句 其中以 for 语句最为常用。 本节后面还会讨论循环相关的 C 语言特性。如 break，continue，goto 语句 一 while 语句 1. while 的基本用法 while 的基本格式如下： 执行 while 语句时，首先计算控制表达式的值。如果值不为零，那么执行循环体，接着再次判定 控制表达式的真值，如果为真，再次执行循环体。直到控制表达式的真值为假，才会结束 while 语句。 **例1-1：**倒计数程序 关于这个例子，我们可以对 while 进行深度思考： while 循环终止时，控制表达式的值一定为假 。 可能根本不执行 while 循环体 。 while 语句常有多种写法 2. 无限循环 如果控制表达式的值始终非零，while 循环将无法终止。 除非循环体内有控制循环的语句（break，return，goto）或者调用了导致程序终止的函数，非则上面的循环永远不会结束。 程序 1：显示平方表 现编写一个程序来显示平方表。首先程序提示用户输入一个数 n，然后显示出 n 行的输出，每行包含 一个 1 ~ n 的数及其平方值。 参考答案： 程序 2：数列求和 参考答案： 我写的，仅供参考： 或者 while 循环可以这样写： 二 do 语句 1. do while 基本用法 do 语句 和 while 语句其实本质上是相同的。只不过 do 语句 至少会执行一次 循环体。 基本形式： 例2-1 倒计数程序 顺便一提， do 语句最好都加上花括号 。 虽然 do while 没有 while 语句使用的那么多，但是前者对于 至少需要执行一次 的循环来说是十分方便的。 程序 3：编写一个程序计算用户输入的整数的位数 参考答案： 如果我们用 while 循环: 如果你输入的是 0 ，这个循环会直接退出。这不符合我们的预期，0 也是整数啊，而且它有 1 位数。 三 for 循环 现在开始介绍 C 语言最后一种循环，也是 功能最强大 的一种循环：for 语句。它是我们用的最多的一种循环，一定要熟练掌握。 1. for 语句的基本用法 例3-1 倒计数程序 在执行上面这个 for 语句时，i 先初始化为 10；然后判定 i 是否大于 0 ；因为结果为真，执行循环体；然后对变量 i 进行自减操作；然后再次判断 i 是否大于 0 ... 直到最后一次 i 自减后，i > 0 不成立了，退出循环。 for 循环如果我们用 while 语句 也可以模拟： 抽象一下即为： for 循环中的 i-- 可以写成 --i 吗？ 即： 这种做法对循环没有任何影响。 i++ 和 i-- 在 for 循环的 表达式3 中是完全等价的。 2. 在 for 语句中省略表达式 1.省略第一个表达式 注意： 省略第一个表达式相当于不初始化 i 即便 第一个表达式省略了，也 不能省略后面的分号 2. 省略第三个表达式 3. 省略第一个和第三个表达式 是不是很像我们的 while 语句？ 4. 省略第二个表达式 省去控制表达式， 默认为真 ，因此 for 语句会不断循环下去。 它相当于： 3. C99 中的 for 语句 C99 中第一个表达式可以替换成一个声明： 变量 i 不需要在该句之前进行声明。事实上，如果变量 i 在之前已经声明了，这个语句会创建一个新的 i 且该值 仅用于循环内 。（新的 i 会覆盖之前的 i 。for 语句的花括号相当于一个块，新 i 的作用域仅在这个块内。） 例如： 输出： 最后输出的 20 不符合我们的预期，这说明了一个问题： 旧的 i 始终存在，只不过在 for 语句内，新的 i 相对旧的 i 显“显性”（可以用高中生物的基因来理解一下；或者你就理解为覆盖，但是新的 i 开辟的是新的内存，它不会真的覆盖旧的 i ）。 for 语句声明的变量不可以在循环外访问（生存期仅在 for 语句内）。例如： 让 for 语句声明自己的循环控制变量通常是一个好办法：这样 方便且程序的可读性更强 ，但是如果 在 for 循环退出后还要使用该变量 ，则只能使用以前的 for 语句格式。 另外，for 语句可以 声明多个变量 ，只要它们的 类型相同 ： 4. 逗号表达式 逗号表达式（comma expression） 基本用法 逗号表达式的优先级低于所有其他运算符 从左向右依次执行：先计算表达式1，然后是表达式2 ... 最终整个逗号表达式的值为最后一个表达式的值 例如： 相当于是： 整个逗号表达式的值为 k = i + j 的值，也就是 3 应用 假如在进入 for 语句时希望初始化两个变量 改写为： 程序 4：显示平方表（for） 程序 5：不用乘法，实现程序“显示平方表” 四 退出循环 有时， 循环还没有结束，但我们需要在循环的中间设置退出点 ，甚至可能需要对循环设置多个退出点。break 语句可以满足这种需求。 continue 语句用来跳过某次迭代的部分内容，但是不会跳过整个循环。 goto 语句允许程序从一条语句直接跳到另一条语句。 1. break 语句 break 语句不但可以把程序控制从 switch 语句中转移出来；还可以用于跳出 while，do while，和 for 循环。 程序 6：判断素数 假如要编写一个程序判断 n 是否为素数。我们计划是编写一个 for 语句用 n 除以 2 到 n - 1 之间的所有数。一旦发现有约数就跳出循环，没不需要继续尝试下去。循环终止后，可以用 if 判断循环是提前终止（不是素数）还是正常终止（是素数）。 程序 7：程序2 重写 程序 2 中，要求我们当用户输入 0 时，结束求和。我们用很多中方法去实现了，我们可以用 break 语句让程序的意图更加直观。 一个 break 只能跳出一个循环 ，如果循环嵌套，那么可能需要多个 break 才能从里层的循环跳出。 运行一下这个程序，发现，屏幕上一直在输出 5，说明外层的循环没有跳出。 2. continue 语句 break 语句刚好将程序控制转移到循环体的 末尾之后 ；而 continue 语句刚好将程序控制转移到循环体 末尾之前 。 break 语句会跳出循环；而 continue 语句会将程序控制留在循环体之内。 其实 continue 的作用就是跳过本次循环点剩下内容，重新开始下一轮循环。 程序 8：计算奇数和 编写一个程序。先提示用户输入一个数 n，然后将 1 ~ n 的所有奇数相加，然后输出最终的和 不使用 continue 使用 continue 只需要改变 for 循环即可 3. goto 语句 break 和 continue 语句都是跳转语句：它们可以把控制从程序的一个位置转移到另一个位置。然而，这两者都是受限制的。 goto 语句则可以跳转到函数中任何有标号的语句处。（C99 增加了一条限制：goto 不能用于绕过 变长数组 （后面的章节会讲）的声明。） 程序：程序 6 重写 goto 语句在早期的编程语言中很常见，但是日常 C 语言编程却很少使用它了。break，continue，return 语句（本质上都是受限制的goto 语句）和 exit 函数足以应付大多数需要使用 goto 的情况。 而且 goto 语句 不建议滥用，能不用就不用 ，因为 goto 语句可以打乱程序原本的执行顺序，这大大降低了程序的可读性，提高了改错成本。（这里程序圆是有切身体会的，大一 做C语言的课设的时候，那时候对 C 掌握的并不是很好。我在一个函数内大量的使用 goto 语句，导致我最后都不知道我的 goto 跳到哪里了。） 但是 goto 语句偶尔还是有用的，比如上面的例子：循环嵌套的情况，使用goto 语句就很好跳出多重循环了： 程序：账薄结算 这个程序帮你理解一种简单的交互式程序设计，我们可以通过这种方式设计菜单。 题目： 开发一个程序用来维护账簿的余额。程序将为用户提供选择菜单：清空余额账户，向账户存钱，从账户取钱，显示当前余额，退出程序。选项用 0，1，2，3，4表示。程序的会话类似这样： 参考程序： 五 空语句 第二个语句就是一个空语句： 除了末尾的分号，什么符号也没有。 空语句主要有一个好处： 编写空循环体的循环。 判断素数的循环我们可以这样改写： 注意不要写成： 程序员习惯将空语句单独放在一行 ，否则，一般人阅读程序时可能会混淆后面的语句是否是其循环体。 一般情况下，将普通循环转化为带空循环体的语句 不会提高效率 ，但是会让程序更加简洁。 但是在一些情况下，可能带空循环体的循环会更加高效。如：读取字符数据时（后面会讲）。 空语句可能会造成的情况 1.if 语句 如果输入的 d 不是 0，后面的这条消息一样会被打印。 2.while 语句 程序会进入死循环，因为 while 没有循环体，而 i 的的值不会减小。 循环终止，但是花括号内的语句只被执行一次。 输出：0 3. for 语句 printf 语句被执行一次 输出：0 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 用100个函数操作一个数据结构比仅用10个函数但是操作10个不同的数据结构要好。 Epigrams on Programming 编程警句 ↩"},{"title":"数组","slug":"c-modern-approach/09-arrays","permalink":"/kb/posts/c-modern-approach/09-arrays","category":"c-modern-approach","description":"C语言一维数组、多维数组的声明、初始化与使用","date":"2026-06-16T00:00:00.000Z","content":"数组 Get into a rut early: Do the same processes the same way. Accumulate idioms. Standardize. The only difference (!) between Shakespeare and you was the size of his idiom list - not the size of his vocabulary. 1 目录 [TOC] 数组 到目前为止，我们所见的变量都只是 标量 （scalar）：标量具有保存单一数据项的能力。 C 语言也支持 聚合 （aggregate）变量，这类变量可以存储一组数值。C 语言一共有两种聚合类型： 数组 （array）和 结构 （structure）。 其中，数组是本节的主角。它只能存储 相同类型 的变量集合。 一 一维数组 数组是含有 多个 数据值的 数据结构 ，并且每个数据具有 相同的数据类型 。这些数据值称为 元素 （element）。 最简单的数组是 一维数组 。一维数组中的每个元素一个接一个的排列。 为了声明数组，需要指明数组元素的 类型和数量 。 数组的元素可以是任意类型，数组的长度可以是任何**（整数）常量表达式**指定。 但是不能使用变量（C89） 尽管 C99 已经允许这种做法，但是，很多编译器并不完全支持 C99 。 1. 数组下标 对数组 取下标（subscripting） 或进行 索引（indexing） ：为了取特定的数组元素，可以在写数组名的同时在后面加上一个用方括号围绕的整数值。 数组元素始终 从 0 开始 ，所以长度为 n 的数组元素的索引时 0 ~ n - 1 例如，a 是含有 10 个元素的数组： a[i] 是左值，所以数组元素可以像不同变量一样使用： 2. 数组和 for 循环 许多程序包含的 for 循环都是为了对数组的每个元素执行一些操作。下面给出了长度为 N 的数组 a 的一些常见操作。 注意：在调用 scanf 函数读取数组元素时，就像对待普通变量一样，必须使用取地址符号 &#x26; C 语言不要求检查下标的范围。当下标超出范围时，程序可能执行不可预知的行为。 数组下标可以是任何整数表达式： 下标可以自增自减： i = 0 时进入循环，打印 a[0] 后 i 的值增加 1 ，这样不断重复；当 i = N - 1 时，打印 a[N - 1] 然后 i 的值加 1 变为 N 不满足 控制表达式 退出循环。 这类问题可以使用 VS 进行调试，从而判断数组下标的变化情况，如果你不会调试可以留言告诉我，不会的人多的话，我可以出一期教程。以后能调试解决的问题不再赘述。 使用自增自减运算符的时候一定要注意，如果这样子写： 将自增自减从下标中移走即可： 程序：数列反向 要求录入一串数据，然后按反向顺序输出这些数： 参考程序： 这个程序使用宏的思想可以借鉴。 3. 数组初始化 数组初始化（array initializer） 一般的初始化方法： 如果初始化式子比数组短，那么剩余的元素被赋值为 0 利用这一特性，可以很容易的将数组全部初始化为 0： 如果给定了数组的初始化式，可以省略数组长度： 编译器利用初始化式的长度来确定数组大小。数组仍有固定数量的元素。 4. 指定初始化（C99） 经常有这样的情况：数组中只有相对较少的元素需要进行显示的初始化，而其他元素可以进行默认赋值。 比如： 对于更大的数组，如果使用这种方式赋值，将是 冗长 而 容易出错 的。 C99 中的指定初始化可以用于解决这一问题： 括号中的数组称为 指示符 。 注意： 赋值顺序不是问题。 写成这样也是 ok 的。 指示符必须是 常量表达式 。 如果待初始化的数组长度为 n ，那么指示符的取值为：[0, n-1]；如果数组长度是省略的，指示符可以是任意非负数，编译器将根据 最大的指示符 推断出数组长度。 最大的指示符为 10，数组长度为 11 初始化式中新旧方法可以混用 指示符后如果使用旧的方法初始化，那么初始化的元素应该紧邻指示符之后。 数组 a 的元素个数为 12 个 如果新旧初始化方法混用，此时，数组 a 的大小就要看情况：如果最大的指示符后有旧的初始化方法，那么数组长度应该加上直到下一个指示符前的所有元素个数。 程序：检查数中重复出现的元素 检查数中是否有出现多于 1 次的数字。 1 ）判断是否存在重复出现的数字。 2）输出所有重复出现的数字。 参考答案： 1） 2） 对于第一个程序，如果你的编译器不支持头 &#x3C;stdbool.h>，你可以自己定义宏，这个我们之前说过。或者就用 0 1 也可以。 5. 对数组使用 sizeof 运算符 数组的大小是数组每个元素大小的总和，也就是：数组元素个数 x 数组数据类型的大小 上例数组大小为 4 x 10 = 40 （int 大小为 4 的机器上）。 也可以用 sizeof 计算数组元素的大小： 此外还有我们经常使用的：**计算数组长度：**用数组的大小除以每个元素的大小 细心的你可能已经发现，为什么我用的 printf 的转换说明都是 %zu 这是因为 sizeof 的返回值类型是 size_t 类型（unsigned int）， %zu 是专门为这种类型设置的转换说明。 所以，有时候当你这样写程序时，可能会有报错： 这时因为 i 和 sizeof(a) / sizeof(a[0]) 类型不一样，可以强制类型转换一下： 如果你嫌麻烦，可以使用宏定义数组长度，但是如果两个数组大小不一样，你就要定义两个宏。 这时候我们可以使用带参数的宏： 如果不懂，也没有关系，后面我们会详细讲解。 程序：计算利息 编写一个程序显示一个表格。这个表格显示了几年时间内 100 美元投资在不同利率下的价值。用户输入利率和要投资的年数。投资总价值一年算一次，表格将显示输入的利率和紧随其后的 4 个更高的利率下投资的总价值。程序会话如下： 第一行用一个 for 语句来显示。 我们在计算第一年的价值的时候将结果存放到数组中，然后使用数组中的结果继续计算下一年的价值。 在这一过程中我们将需要两个 for 语句，一个控制年份，一个控制不同的利率。 程序示例： 二 多维数组 数组可以有任意维数。不过多维数组我们一般只使用 二维数组 。 二维数组的声明： a[i][j] 访问的时 第 i 行 第 j 列的元素。 虽然我们以表格的形式显示二维数组，但是实际上它们在计算机的内存中是按照 行主序 线性存储的，也就是从第 0 行开始。 所以上面的数组实际是这样存储的： 基于这个特性，我们一般用嵌套的 for 循环遍历二维数组： 1. 多维数组初始化 嵌套的一维数组初始化式： 缺省： 我们只初始化了第 1 行第 1 个元素，第 2 行第 1，2 个元素，其余的元素初始化为 0 甚至可以不写内层的大括号： 一旦编译器填满一行，就开始填充下一行。 试思考，如果这样初始化二维数组，结果会是怎样： 第一行被初始化为 1，2，3 其余都为 0 C99 的指定初始化对多维数组也有效。例如： 像通常一样，没有指定值的元素默认置 0 多维数组的初始化可以省去第一维（二位数组中的行），其他维度不能省略。 2. 常量数组 用 const 修饰的数组，数组元素无法被改写（只读）。 程序：发牌 下面这个程序说明了二维数组和常量数组的用法。 要求： 程序负责发一副标准纸牌。每张标准指派都有一个花色（梅花，方块，红桃，黑桃）和一个点数（2 ~ 10, J, Q, K, A）。用户需要指明发多少张牌： **程序说明: ** 创建两个常量数组，分别放置 4 中花色 和 13 个点数 程序要可以生成 随机数 。我们需要三个函数： time &#x3C;time.h> srand &#x3C;stdlib.h> rand &#x3C;stdlib.h> 这三个函数组合就可以完成这一功能，原理在我另一篇文章：【随机数发生器】 中讲解过。 生成的随机数必须在：0 ~ 3 和 0 ~ 13 之间： 只需要让 rand() % 4 那么随机数就在 0 ~ 3 之间，另一个同理。 两次拿到的牌不能是一样的。创建一个 bool 类型的数组，开始时全部初始化 false。每次拿到两个随机数后，如果数组对应的值为 false 那么将该元素置为 true 然后将此牌“发”给用户；否则，重新生成随机数。 参考程序： 三 变长数组（C99） 前面我们说到，数组变量的长度必须用 常量表达式 进行定义。但是 C99 中，可以使用 变量 作为数组长度。 例如： 变长数组 （variable-length array,简称VLA）：变长数组的长度时 程序执行时 计算的，而 不是在编译时 计算的。 如果不用变长数组，我们需要指定一个固定的长度。往往我们必须要给足大小，避免数组太小存放不下，导致程序出错。如果某一次程序只需要很少的空间，那么势必会造成巨大的内存浪费。 VS 2019 不换其他的编译器的情况下，是不支持 C99 这一特性的。所以，当时我写程序的时候，往往会开辟一个比较大的数组，每次都感觉很呆板。 作为开始学习的新手，建议就用 define 定义的宏来规定数组长度，这样使得程序更加易度和专业。 如果你学有余力，那么可以去学习一下动态内存分配函数 ，使用 malloc 来达到程序运行时创建合适大小的数组。我的文章也写过多次 动态内存分配 函数，有兴趣可以去看看。 变长数组的限制： 没有静态存储期限 不能初始化 变长数组常见于除了 main 函数以外的其他函数。对于函数 f 而言，变长数组最大的优势就是每次调用 f 时 数组的长度可以不同。 参考资料：《C Primer Plus》《C语言程序设计：现代方法》 Footnotes 早立规矩：同样方式做的同样处理。积累固定用法(idiom)。标准化。你和莎士比亚的唯一区别是成语(idiom)量——不是词汇量 Epigrams on Programming 编程警句 ↩"},{"title":"函数","slug":"c-modern-approach/10-functions","permalink":"/kb/posts/c-modern-approach/10-functions","category":"c-modern-approach","description":"If you have a procedure with 10 parameters, you probably missed some. [^1]","date":"2026-06-16T00:00:00.000Z","content":"函数 If you have a procedure with 10 parameters, you probably missed some. 1 目录 [TOC] 函数 零 前言 函数这个概念源自数学。在其他语言中，函数也叫方法，过程等。所以，编程中函数与数学中的函数是不同的。 我们早在第二章就接触到函数这一概念—— main 函数。当时我煞费苦心的尝试用通俗的话向你们解释 main 函数的构成以及各部分的功能。但我们刚开始学编程，对函数一定不能有一个比较深刻的认识。这一节，我将带领大家走进函数，仔细推敲品味函数。从这一节开始，使用函数的思想将会伴随我们今后的编程生涯。 一个程序可以实现很多复杂的功能，然而如果将所有的功能写在一个 main 函数中，显然是不科学的。大型程序的代码肯定不是几十行的事情，如果这样做，会让你在写代码和修改 bug 时崩溃。退一步说，对于一个项目来说，肯定时多人合作共同编写，都写在 main 函数内，就很僵硬了。 所以，函数就是一个子程序，它可以将我们的程序 模块化 ，不同的函数实现不同的功能，然后再通过一定的方法让它们有机结合起来。将程序模块化，还可以减少不必要的重复——不用重复编写功能相同的代码。 这一章，会教你如何编写函数，并且更加深入的理解 main 函数本身。 一 函数的定义和调用 在介绍函数的定义之前，让我们先来看 3 个简单定义的函数。 这三个函数我就不详细分析了，你可以打开我之前讲 main 函数的构成那篇文章，和 main 函数对比着看。 1. 3 个 简单的函数 ① 计算平均值 假设计算两个 double 类型的数值的平均值。 ② 显示倒计数 不是每一个函数都有返回值： ③ 显示双关语 不是每个函数都有参数： 2. 函数定义 返回类型 函数的返回类型是函数返回值的类型。 函数 不能返回数组 返回类型是 void 表示 没有 返回值 如果省略返回值，C89 会 假设函数返回的是 int 类型 ；C99 中这是不合法的。 一些程序员喜欢将返回类型放在函数名的 上边 ： 如果 返回类型很长 ，比如 unsigned long int 类型，那么这样写是非常有用的。 形式参数 每个形式参数前需要写明其类型，形参之间用逗号隔开。 【C语言程序设计——现代方法】这本书中写到：“如果函数没有形式参数，那么圆括号内应该出现 void ” 注意 ：即使几个形参具有相同的数据类型，也必须对每个形参分别进行类型说明。 函数体 C89 中，变量声明必须出现在语句之前。 C99 中，允许声明和语句混在一起，只要在第一次使用之前进行声明即可。 C89 C99 块 块（block）：一对花括号内就是一个块 我们在讲循环时说过，如果你这样写 for 语句： 在 for 语句内定义变量 i ，那么当 for 循环结束后，后面的程序没有办法再去使用 i 了，因为 i 已经不存在了。 for 语句的大括号其实就是一个块。 在块内定义的变量只属于这一个块，块外的程序是没有办法访问和修改块内定义的变量的。 如果你还是不理解，可以看看下一章内容中的 作用域和生存期 。 3. 函数调用 函数调用由函数名和实参列表组成，实参列表用圆括号括起来： 返回值非 void 的函数会产生一个值，该值可以存储在变量中，还可以进行测试，显示或者其他用途。 如果不需要非 void 函数返回的值，总可以将其丢弃： average 函数的这个调用就是一个表达式语句的例子：计算出结果，但是不保存它 有时候我们可以直接将函数调用产生的结果当做 printf 函数的参数： 这种做法其实也是丢弃了 average 的返回值。 说到丢弃返回值，我们最常用的两个函数 printf 和 scanf 也是有返回值的： 我们看看 MSDN 中对 printf 返回值的描述： The function returns the number of characters printed, or a negative value if an error occurs. 我们看看 MSDN 中对 scanf 返回值的描述： scanf return the number of fields successfully converted and assigned; the return value does not include fields that were read but not assigned. A return value of 0 indicates that no fields were assigned. 为了清楚的表明函数的返回值是被故意丢弃的，C 语言通常允许在函数调用前加上 void : 如此一来，printf 函数的返回值强制类型转换成 void 类型。 但是，C 语言库中大量函数的返回值通常都会被丢掉；在调用它们时都使用 (void) 会很麻烦，所以我们一般不这么写。 程序：判断素数 编写程序提示用户录入数，然后给出一条信息说明此数是否为素数。 把判断素数的实现写到另外一个函数中，此函数返回值为 true 就表示是素数，返回 false 表示不是素数。 参考程序： main 函数中包含一个叫 n 的变量，is_prime 函数中也有一个叫 n 的变量。这两个变量是虽然同名，但是在内存中的地址不同，是完全不相同的。所以给其中一个变量赋新值不会影响另一个变量。下一章我们还会详细的讨论这个问题。 is_prime 函数中有多条 return 语句。但是任何一次函数调用只能执行其中一条 return 语句，这是因为执行 return 语句后函数就会返回到调用点。本节后面还会深入的学习 return 。 二 函数声明 在本节前面的程序中，函数的定义总是放置在调用点的上面。C 语言并没有要求函数的定义必须在调用点之前，如果我们这样写： 当程序执行到 main 函数中的 average 函数调用时，编译器没有任何关于 average 函数的信息：编译器不知道 average 函数有多少形式参数，形式参数的类型是什么，也不知道函数的返回值类型是什么。但是，编译器不会给出出错消息，而是假设 average 函数返回 int 型的值。我们可以说编译器为该函数创建了一个 隐式声明 （implicit declaration）。编译器无法检查传递欸 average 函数的参数个数和类型，只能进行默认的 实际参数提升 （见第三部分）并期待最好的情况发生。当编译器遇到后面的 average 函数时发现函数的返回类型是 double 而不是 int ，从而我们得到一个出错消息。 比如： 为了避免这种定义前调用的问题，一种方法是使每个函数的定义都出现在其被调用之前。然而这种方法不够灵活，那么如何可以让函数的定义的位置可以自定义呢？ 函数声明： （function declaration）在调用前声明每个函数使得编译器可以先对函数进行概要浏览，而函数的定义可以以后再给出。 函数的声明类似函数的第一行： 为 average 函数添加声明后程序的样子： 为了和过去的那种圆括号内为空的函数声明风格相区别，我们把这种函数声明称为 函数原型 （function prototype）。函数原型为如何调用函数提供了完整的描述：返回值类型，实参个数和类型。 上面这句中“和过去的那种....”这里应该如何理解？我们用过去的方法对 average 函数的声明可以是这样的： 也就是可以不用写形参列表。 参考文章： https://www.cnblogs.com/pmer/archive/2011/09/04/2166579.html 函数原型 不需要说明函数形式参数的名称，只要显示类型即可： 通常最好不要省略形参名称 ，因为这些名字可以说明每个形参的目的，并且提示程序员再函数调用时实际参数的顺序。 C99中遵循这样的规则：再调用一个函数之前，必须先对其进行声明或定义。如果没有声明而直接调用，会导致出错。 三 实际参数 形式参数： (parameter) 出现再函数的定义中 实际参数： （argument）出现在函数调用中的表达式。 在 C语言中，实际参数是通过 值传递 的：调用函数时，计算出每个实际参数的值并将它赋值给相应的形式参数。在函数执行的过程中，形式参数的改变 不会 影响实参的值，这是因为 形式参数是实参的副本 。从效果上来讲，每个形式参数初始化为相应的实参的值。 实际参数按值传递有利有弊。 利：可以直接修改形参的值 比如：计算 x 的 n 次方 我们可以在函数内直接修改 n 来减少引入的变量： 弊：如果我们需要函数返回一个以上的值，那么按值传递显然是无法直接做到的 例如：我们需要设计一个函数，将 double 类型的值分解成整数和小数部分。因为无法返回两个数，所以通过返回值返回我们计算出的整数部分和小数部分是不现实的。所以可以尝试传入两个变量给函数并修改它们： 前面我们也说了，这显然也是不现实的。因为形参的改变无法修改实参。 如果你感到困惑，我们可以来测试一下：我们在 main 函数中调用这个函数： 1. 实际参数转换 C 语言允许实际参数类型和形式参数类型不匹配的情况下进行函数调用。 **编译器在调用前遇到原型：**就像赋值一样，将实际参数隐式转换为相应的形式参数的类型。 编译器在调用前没有遇到原型： 编译器执行 默认的实际参数提升 ：1）把 float 类型的实参转换为 double类型。2）把 char，short 类型的实参转换为 int 类型。 默认的实际参数提升可能无法产生期望的结果。思考下例： double 类型的 x 被执行了没有效果的实际参数提升，square 实际期望 int 类型，但是却得到的是 double 类型，所以 square 将产生无效的结果。通过把 square 实参强转为正确类型可以解决这个问题： **更好的方案是在函数调用前提供函数原型。**C99中，调用前不提供没有声明或定义是错误的。 2. 数组型实际参数 数组经常被当作实际参数。当形式参数为一维数组时，可以（而且是通常情况下）不说明数组长度： C 语言没有为函数提供任何简便的方法来确定传递给它的数组的长度，所以通常情况下，我们必须 把数组长度作为额外的参数提供出来 示例：数组求和 **注意：**虽然可以用运算符 sizeof 计算出数组变量的长度，但是它无法给出数组类型的形式参数参数的正确答案： 原因在后面的章节会详细讨论。 上例中 sum_array 函数的函数原型可以省略形式参数的名称： 在调用 sum_array 函数时，不要将顺序写反。 **注意：**把数组名传递给函数时，不要在数组名的后面放置方括号： 数组变量作为函数参数的特性 1)数组无法检测传入的数组长度是否正确 ，所以： 一个数组有 100 个元素，但是实际仅仅使用 50 个元素，实参可以只写 50： 函数甚至不会知道数组还有 50 个元素存在！ 如果实际参数给的比数组还要大，会造成数组越界，从而导致未定义行为 2）在函数中改变数组型形式参数的元素，同时会改变实际参数的数组元素。 多维数组 多维数组的形式参数可以省略第一维的长度，比如 a[][3] 但是，这样的方式不能传递 具有任意列数 的多维数组。幸运的是，我们通常可以通过使用指针数组的方式解决这一问题。 3. 变长数组形式参数 （C99） 4. 在数组参数声明中使用 static （C99） 5. 复合字面量（C99） 以上 3 种 C99 特性这里不做展开，因为我们不常用到，如果你有兴趣，可以自己查询。 四 return 语句 非 void 类型的函数必须使用 return 语句来指定将要返回的值。 表达式可以是 常量: return 0 变量: return a 复杂的表达式: return n >= 0 ? n : 0 如果 return 语句表达式的值和返回类型不匹配，那么系统将把表达式的类型隐式转换为返回类型。 return 也可以出现在返回值类"},{"title":"程序结构","slug":"c-modern-approach/11-program-structure","permalink":"/kb/posts/c-modern-approach/11-program-structure","category":"c-modern-approach","description":"Recursion is the root of computation since it trades description for time. [^1]","date":"2026-06-16T00:00:00.000Z","content":"程序结构 Recursion is the root of computation since it trades description for time. 1 目录 [TOC] 程序结构 零 前言 《C 语言程序设计——现代方法》一书中，将此章节安排在了函数之后。在我看来，这样安排不是很合理。在函数一章中，在解释形参和实参可以同名时，没有这部分知识确实很难去阐述原理。 书中本章开篇第一句话就是“本章来讨论一个程序有多个函数时所产生的几个问题”，其实变量的 作用域 和 生存期 并不是在只有函数的时候才会应用到，举一个很简单的例子：for 循环。 其实这就是我们前面说到的 块 （block）的问题。 学习过程中，【C 必知必会】系列中的【CMOOC】篇中的相关文章大家可以参考一下。 本节的内容不局限于 作用域 和 生存期。废话不多说，我们开始吧。 一 局部变量 函数体内声明的变量称为该函数的 局部变量 。 比如： 变量 i 就是局部变量。 局部变量的性质： 自动存储期限 。变量的 存储期限 （生存期）（storage duration）（或存储长度）。局部变量的存储单元在函数被调用时“自动”分配，函数返回时自动回收，所以称这种变量具有自动的存储期限。包含局部变量的函数返回时，局部变量的值无法保留。当再次调用该函数时，无法保证变量仍拥有原先的值。 块作用域 。变量的 作用域 是可以引用该变量的程序文本部分。局部变量拥有块作用域：从变量声明的点开始一直到所在函数体的末尾。因为变量的作用域不能延伸到其所属的函数之外，所以其他函数可以把同名变量用于其他用途。 这一段介绍写的太书面化了。其实上面说的无非就是生存期和作用域问题。 关于生存期和作用域的程序演示 下面的程序计算数组元素的和： 我们将上面的程序改写为： 用简单的描述一下作用域和生存期： 作用域：限定某个名字的可用性的代码范围就是该名字的作用域 生存期：变量值存在的时间 块：一个花括号 {} 就是一个块。 通常来说，变量的作用域和生存期都是在一个块内。 上面第二个程序的执行结果和第一个完全一样，我们现在来一步一步分析一下： C99 不要求函数在一开始的位置进行变量声明。所以局部变量的作用域可能会很小 。 1. 静态局部变量 在局部变量中放置单词 static 可以使变量具有 静态存储期限 而不再是自动存储期限。 因为具有静态存储期限的变量拥有永久的存储单元，所以在整个程序的执行期间都会保留变量的值。比如： 在函数 func 返回时，变量 n 的值不会丢失。 静态局部变量虽然生存期是整个程序，但是作用域尽在其所定义的块内。也就是说，上例中函数 func 返回后，func 内的 n 就不再可用。 二 全局变量 全局变量（外部变量 external variable）声明在所有函数体之外。 全局变量的性质 静态存储期限 。 文件作用域 。全局变量的作用域：从变量被声明的点开始一直到所在文件的末尾。外部变量声明之后的函数都可以访问（并修改）它。 程序：全局变量实现栈 栈 是一种只能从一端“进出”的数据结构。这一端被称为栈顶。 我们可以用数组来模拟这种数据结构。用一个变量 top 标记当前栈顶的位置。如果数据压栈，则将 top 自增；如果数据出栈，则将 top 自减。我们需要写很多函数来实现这种数据结构，比如 压栈，出栈，判满，判空等等。我们可以将 表示栈顶的变量 top 和 表示栈的数组定义为全局变量。这里有一段代码（不是完整的程序）： 全局变量的利与弊 **利：**多个函数共享一个变量时或者少数几个函数共享大量变量时，外部变量很有用。 然而在大多数情况下，对于函数而言， 传参比共享变量更好 。原因如下： 弊： 在程序维护期间，如果改变全局变量（比方说改变其类型），那么将需要检查同一文件中的每个函数，以确认该变化如何对函数产生影响。 如果全局变量被赋了错误的值，可能很难确定出错的函数。 很难在其他程序中复用依赖于全局变量的函数。依赖全局变量的函数不是“独立”的。为了在另一个程序中使用该函数，必须带上此函数需要的全局变量。 如果全局变量在多个函数中使用（比如 for 循环的控制变量 i），让人误认为变量的使用彼此关联，而实际可能并非如此。 **注意：**使用全局变量时，要确保它们的名字都有意义。如果你发现全局变量的名字就像 i ， temp 一样，这可能意味着这些变量其实应该是局部变量。 将局部变量声明为全局变量可能会导致一些问题 。思考下例： 此时，print_all_row 打印的不是 10 行，而是 1 行。第一次调用 print_one_row 函数返回时， i 的值将为 11 ，不满足 for 的控制表达式，循环退出。 所以， 全局变量建议不要使用。 程序：猜数 程序产生一个 1 ~ 100 的随机数，用户尝试用尽可能少的次数猜出这个数。程序运行如下： 程序示例： 使用全局变量 不用全局变量 不用全局变量我们就需要让产生随机数的函数返回产生的随机数，然后将随机数当作参数传给 read_guesses() 函数。 三 构建 C 程序 从 猜数 的程序中你应该大体可以感受到如何从头到尾去写一个 c 程序。我们这里给出比较好的编排顺序： #include 指令 #define 指令 类型定义 全局变量声明 函数原型 main 函数定义 其他函数定义 多写写程序自然会领略到其中的道理。 程序：手牌分类 编写程序对手牌进行读取和分类。手中的每张牌都有花色（方块，梅花，红桃和黑桃）和等级（2，3，4，5，6，7，8，9，T，J，Q，K 和 A）。不允许使用王牌，并且假设 A 是最高等级的。一手 5 张牌，然后把手中的牌分为下列某一类（列出的顺序从好到坏）。 同花顺（顺序连续且同花色） 四张（4 张牌等级相同） 葫芦（3 张牌等级一样，另外2 张等级一样） 同花（5 张牌同花色） 顺子（5 张牌等级顺序连续） 三张（3 张牌等级连续） 两对 一对（2 张牌等级一样） 其他牌 如果一手牌可以分为两种或多种类别，程序将选择最好的一种。 为了便于输入，将牌的等级和花色简化如下： 等级： 2，3，4，5，6，7，8，9，t，j，q，k ，a 花色：c d h s 如果用户输入非法牌或者输入同一张牌两次，程序将此牌忽略掉，产生错误信息，然后要求输入另一张牌。如果输入为 0 而不是一张牌，就会导致程序终止。 与程序的会话如下： 程序示例： 参考资料：《C语言程序设计：现代方法》 Footnotes 递归是计算之母。她用描述换取时间。 Epigrams on Programming 编程警句 ↩"},{"title":"指针","slug":"c-modern-approach/12-pointers","permalink":"/kb/posts/c-modern-approach/12-pointers","category":"c-modern-approach","description":"C语言指针的声明、取地址、间接寻址与指针赋值","date":"2026-06-16T00:00:00.000Z","content":"指针 If two people write exactly the same program, each should be put in micro-code and then they certainly won't be the same. 目录 [TOC] 指针 零 前言 指针是 C 语言最重要——也是最常被误解——的特性之一。本节重点介绍指针的基础内容。 一 指针变量 现代大多数计算机将内存分割为 字节 （byte），每个字节可以存储 8 位的信息： 0000 0001 。 每个字节都有唯一的 地址 （address），用来和内存种的其他字节相区别。如果内存中有 n 个字节，那么可以把地址看作 0 ~ n - 1的数。 可执行程序由 代码 （原始 C 程序中于语句对应的机器指令）和 数据 （原始程序中的变量）两部分构成。程序中的每个变量占有一个或多个字节，把 第一个字节的地址 称为是变量的地址。 上图中，i 占有的字节是 2000 ~ 2003 4 个字节，2000 就是 i 的地址。 虽然用数表示地址，但是地址的取值范围可能不同于整数的取值范围，所以一定不能用普通的整型变量存储地址。 但是，我们可以用特殊的 指针变量 （pointer variable）存储地址。在用指针变量存储 p 存储变量 i 的地址时，我们说 p “指向” i 。换句话说，指针就是地址，而指针变量就是存储地址的变量。 1. 指针变量的声明 上述声明说明 p 是指向 int 类型对象的指针变量 。这里我们用术语 对象 代替 变量 ，这是因为 p 可以指向不属于变量的内存区域。（后面会讲） 指针变量可以与其他变量一起出现在声明中： C 语言要求每个指针变量 只能指向一种 特定类型（引用类型）的对象。 关于指针变量声明中 * 与谁挨着的问题 ： 请看下面的声明： 请问，上面的声明中 p 和 q 都是指针变量吗？ 小黄：我觉得是，如果你写成这样： 那就是只有 p 是指针变量了。 程序圆：你这样想就大错特错啦，上面这两种写法是 等价的 。都是声明 p 为指针变量而 q 是一个普通的 int 类型变量。 小黄：哦~那我们平时应该选择那种写法呢？ 程序圆：通常情况下我们都是选择第一种写法，即： int* p 。但是这样确实容易造成误解，所以我们通常一行只声明一个指针变量就可以了。 二 取地址运算符和间接寻址运算符 1. 取地址运算符 声明指针变量时我们没有将它指向任何对象： 在使用之前初始化 p 是至关重要的。使用 取地址运算符 &#x26; 把某个变量的地址赋值给它。 现在 p 就指向了整型变量 i 我们也可以声明的同时初始化： 甚至可以这样： 但是需要先声明 i 2. 间接寻址运算符 间接寻址运算符也叫 解引用 运算符，我个人还是喜欢叫它用解引用运算符。 指针变量 p 指向 i，使用 * 运算符可以访问存储在对对象中的内容（访问存储在指针变量指向的地址上的内容）。 “ * 和 &#x26; 互为逆运算”： 只要 p 指向 i，*p 就是 i 的 别名 。** p 不仅拥有和 i 相同的值，而且 p 的改变也会改变 i 的值。 注意： 解引用 未初始化 的指针变量会导致 未定义行为 ： 给 *p 赋值尤为危险 。如果 p 恰好具有有效的内存地址，程序会试图修改存储在该地址的数据： 这是极度不安全的行为。好在我们的编译器会给出警告。即使这样使用了，编译器不会真的让你去修改其他地方（比如操作系统等）的数据。 所以如果你定义的指针特别多，你也不知道那个会被用上，可以这样初始化指针变量： 然后在需要对 p 解引用的地方添加一个判断： 三 指针赋值 C 语言允许 相同类型 的指针变量进行赋值。 或者直接初始化并赋值： 现在可以通过改变 *p 的值来改变 i ： 不要将 *q = *p 和 q = p 搞混，前者是将 p 指向的对象的值（变量 i 的值）赋值给 q 指向的对象（变量 j）中。 四 指针作为参数 还记得之前分解小数的函数 decompose 吗？我们曾将想在这个函数中通过改变形参来改变实参，但是我们失败了，今天我们再来重新看一下如何用指针作为参数完成这一任务： 将 decompose 函数定义中的形参 int_part 和 frac_part 声明成指针类型。 调用该函数： 当函数调用完成，实参 i 和 d 的值也修改了。你可以再 main 函数中输出一下 i 和 d 测试一下。 用指针作为参数其实并不新鲜： 必须将 &#x26; 放在 i 前以便传给 scanf 函数指向 i 的指针，指针会告诉 scanf 函数将读取的值放在那里。如果没有 &#x26; 传递给 scanf 的将是 i 的值。 虽然 scanf 函数的实参必须是指针，但是并不是总需要 &#x26; 运算符： p 已经包含了 i 的地址，所以不需要 &#x26;。使用 &#x26; 是错误的： scanf 函数将把读入的整数放在 p 中而不是 i 中。 注意： 向函数传递需要的指针却失败了可能会造成严重后果。比如，如果我们在调用 decompose 函数时没有在 i 和 d 前加上 &#x26; ： 函数期望的第二和第三个参数是指针，但传入的却是 i 和 d 的值。decompose 函数没有办法区分，所以它会把 i 和 d 的值当作指针来使用（指针本身是整数）。当函数修改 *int_part 和 *frac_part 时，它会修改未知的内存地址，而不是修改 i 和 d。 如果已经提供了函数原型，那么编译器将告诉我们实参类型不对。然而对于 scanf 来说，编译器通常不会检查出传递指针失败，因此 scanf 函数特别容易出错。 程序：找出数组中的最大元素和最小元素 与程序的交互如下： 参考程序： 用 const 保护参数 指针传参时，可以使用 const 来表明函数不会改变指针参数所指向的对象。 const 应放于形参中： 试图改变 *p 时编译器会报错。 小黄：const 一定只能放在 int 之前吗？这样写合不合法: 程序圆：合法。但是和上面那种声明方式的含义不同。上面的 const 修饰的是 *p，使得 *p 不能被修改；而这一种 const 修饰的 p，使得 p 的指向不能发生改变： 你甚至可以这样写： 这和第一中写法是同一个意思：使得 *p 不能被修改。 五 指针作为返回值 请看返回值类型为 int* 类型的函数 max： max 返回较大数的指针。 调用： 需要使用相同的指针类型接收返回值。 注意： 永远不要返回指向 自动局部变量 的指针： 一旦 f 返回，i 就不存在了，所以指向 i 的指针是无效的。有的编译器可能给出警告：“function returns address of local variable” 参考资料：《C语言程序设计：现代方法》"},{"title":"指针和数组","slug":"c-modern-approach/13-pointers-and-arrays","permalink":"/kb/posts/c-modern-approach/13-pointers-and-arrays","category":"c-modern-approach","description":"In the long run every program becomes rococo - then rubble. [^1]","date":"2026-06-16T00:00:00.000Z","content":"指针和数组 In the long run every program becomes rococo - then rubble. 1 目录 指针与数组 零 前言 C 语言中指针和数组的关系是非常紧密的。当指针指向数组元素时，C 语言允许对指针进行算术运算（加减），通过这种运算我们可以用指针取代数组下标对数组进行处理。 一 指针的算数运算 我们可以通过 p 访问 a[0]: C 语言只支持 3 种格式的指针算数运算： 指针加上整数 指针减去整数 两个指针相减 1. 指针加整数 指针 p 加上整数 j 产生指向特定元素的指针，这个特定元素是 p 原先指向的元素的后的 j 个位置。也就是说如果 p 指向 a[i]，那么 p + j 指向 a[i + j]，前提是 a[i + j] 存在。如图： 2. 指针减整数 如果指针 p 指向数组元素 a[i]，那么 p - j 指向 a[i - j] 。例如： 3. 两个指针相减 两个指针相减结果是指针之间的距离（用数组元素个数来度量）。 如果 p 指向 a[i]，q 指向 a[j]，q - p 等于 j - i 。例如： 注意： 在一个不指向任何数组元素的指针上执行算数运算会导致未定义行为。此外，只有在两个指针指向同一个数组时，把他们相减才有意义。 4. 指针比较 可以用关系运算符（ &#x3C; , > , &#x3C;= , >= ）和判等运算符（ == 和 != ）进行指针比较。只有在两个指针指向同一数组时，用关系运算符进行指针比较才有意义。比较的结果依赖于数组种两个元素的相对位置。如图： 5. 指向复合常量的指针（C99） 略。 二 指针用于数组处理 通过对指针变量进行重复自增来访问数组元素。 for 语句中的条件 p &#x3C; &#x26;a[N] 值得特别说一下。尽管 a[N] 元素不存在（数组下标是 a[0] 到 a[N - 1]），但是对它使用取地址运算符是合法的。因为循环不会检查 a[N] 的值，所以使用 &#x26;a[N] 是十分安全的。执行循环体时，p 依次等于 &#x26;a[0], &#x26;a[1], ..., &#x26;a[N - 1],但是当 p 等于 &#x26;a[N] 时，循环终止。 当然，改用下标可以很容易写出不使用指针的循环。支持采用指针算术运算的最常见论调是，这样作可以节省执行时间。但是这依赖于具体实现——对于有的编译器来说，实际上依靠下标的循环会产生更好的代码。 解引用 与 自增自减 的组合 对于语句： 我们可以用指针改写为： 因为后缀 ++ 的优先级高于 * ，所以上面的语句等同于： 先将 j 赋值给 p 指向的对象，然后 p 指向数组下一个元素。 表达式 含义 (*p)++ *p 自增（后置） *++p 或 *(++p) 先自增 p，然后解引用 ++*p 或 ++(*p) *p 自增 （前置） 我们最常用到的就是 *p++ 。 对数组元素求和时，我们可以将前面写的 for 循环改写为： * 和 -- 的组合和 ++ 类似。 程序：栈实现程序修改 之前我们用整型变量 top 记录栈顶的位置。现在我们用一个指针变量替换 top ，这个指针变量初始为 NULL（不指向任何对象）。 下面是新的 push 和 pop 函数： 三 数组名作为指针 可以用数组名作为指向数组第一个元素的指针 明白了这个原理，我们可以改写 for 语句求和数组元素的程序： **注意：**数组名是被 const 保护的指针： 所以，数组名 a 的指向不能被改变。 这一限制不会给我们造成什么损失：我们可以把 a 赋值给一个指针变量，然后改变该指针变量： 程序：数列反向（改进版） 前面我们讲过一个逆序输出数列的程序。 原来的程序利用下标来访问数组中的元素。我们用指针的算数运算取代数组的取下标操作： 1. 数组型实际参数 数组名在传递给函数时，总是被视为指针。 在给函数传递普通变量时，变量的值会被复制；任何对形参的改变都不会影响到实参。 在给函数传递数组时，数组本身没有复制，而是将首元素的指针赋值给形参；所以对数组形参的改变是可以改变实参的。 比如我们之前写的将数组的每个元素赋值为 0 为了指明数组形参不能被改变，可以在其声明中包含单词 const ： 如果参数有 const，编译器会核实 find_largest 函数体中确实没有对 a 中元素的赋值。 因为向函数传递数组没有对数组进行复制，所以传递大数组不会降低效率，浪费空间。 可以把数组型形参声明为指针。例如： 声明 a 是指针就相当于声明它是数组。编译器把这两类声明看作是完全一样的。 注意： 对形参而言，声明为数组和指针是一样的；但是对变量而言，这是不同的。声明 编译器会预留 10 个整数的空间，但声明 编译器只会预留一个指针变量的空间。在后一种情况下，a 不是数组，试图把它当作数组来使用可能会导致糟糕的后果。例如： 因为我们不知道 a 指向哪里，修改 a 指向的对象的结果是无法预料的。 可以给向形参传递数组“片段”。比如： 上面函数调用的含义就是：从 a[5] 开始检查，检查 10 个元素，从中找出最大值。 2. 用指针作为数组名 既然数组名可以作为指针，指针也是可以看作数组名进行取下标操作的。 编译器将 p[i] 看作是 *(p + i) 。后面我们会进一步讨论它的其他用法。 四 指针和多维数组 指针可以指向多维数组的元素。简单起见，我们在这里只讨论二维数组，但所有内容可以应用于更高维的数组。 1. 处理多维数组的元素 如果把多维数组看作一维数组，可以这样遍历数组： p 从数组的第一个元素地址开始遍历到数组的最后一个元素的地址。 虽然这种写法对大多数 C 的编译器都是合法的。但是明显破坏了程序的可读性，对一些老的编译器来说这种方法提高了效率。但是对许多现代编译器这样所获得的速度优势往往极少甚至没有。 以下内容初学者可以仅作了解即可 2. 处理多维数组的行 为了访问到二维数组的第 i 行的元素，需要初始化 p 使其指向第 i 行的首元素： 等价于： 原理：对于任意数组 a 来说， a[i] 等价于 *(a + i) 。因此，对于二维数组来说， &#x26;a[i][0] 等同于 &#x26;(*(a[i] + 0)) ,因为 &#x26; 和 * 可以抵消，所以该表达式等价于 a[i] 对上面的二维数组第一行的遍历可以这样写： 对于 find_largest 函数来说，我们可以传入某一行的首元素地址，然后让它帮我们计算该行的最大元素： 3. 处理多维数组的列 处理列就要复杂一些。下面的循环遍历数组第 i 列： 这里把 p 声明为指向长度为 COL 的整型数组的指针。在声明 int (*p)[COL] 中 *p 是需要带括号的，如果没有括号，编译器将认为 p 是 指针数组 ，而不是指向数组的指针。表达式 p++ 将 p 移动到下一行开始的位置。表达式 (*p)[i] 中，*p 代表 a 的一整行，因此 (*p)[i] 选中了该行第 i 列那个元素；括号也是必要的，因为编译器会将 *p[i] 解释为 *(p[i]) 4. 多维数组名作为指针 对于多维数组 int a[ROW][COL] 来说，a 不是指向 a[0][0] 的指针而是指向 a[0] 的指针。从 C 语言的观点来看，这样是有意义的。C 语言不认为 a 是二维数组而是一维数组，且这个一维数组每个元素又是一个一维数组。用作指针时，a 的类型是 int (*)[COL] (指向长度为 COL 的整型数组的指针) 。 了解 a 指向的是 a[0] 有助于简化处理二维数组元素的循环。例如，简化上面的遍历数组第 i 列的循环： 调用 find_largest 找到数组最大的元素时，如果我们这样写： 这条语句不能通过编译，因为 find_largest 函数期望的实际类型是 int* 而 a 的类型是 int (*)[COL] 。正确的调用写法是： a[0] 指向 0 行的第 0 个元素。 程序圆寄语： 以上部分可以说是到目前为止我们接触到的指针的最难的层面了。如果你看不懂，那请往下看： 如果你是初学者，那这部分内容对你太过于深了。不建议你现在着急去搞懂它，你需要大量的应用指针编程练习才能对指针有一个比较立体的认识。你只需要掌握前 3 部分内容即可。 如果你在看这篇文章之前已经学过了指针，并且想搞懂这部分内容，那可以去我的【C 进阶】系列查看相关的文章。 后面很快我们就会回过头来继续深挖指针，敬请期待！ 五 C99 中的指针和变长数组 略。 参考资料：《C语言程序设计：现代方法》 Footnotes 程序终将成为洛可可，然后是碎石。 Epigrams on Programming 编程警句 ↩"},{"title":"字符串","slug":"c-modern-approach/14-strings","permalink":"/kb/posts/c-modern-approach/14-strings","category":"c-modern-approach","description":"Everything should be built top-down, except the first time. [^1]","date":"2026-06-16T00:00:00.000Z","content":"字符串 Everything should be built top-down, except the first time. 1 目录 [TOC] 字符串 零 前言 前几章虽然我们用过 char 类型变量和 char 类型数组，但我们始终没有谈到处理字符序列（C 的术语是 字符串 ）的便捷方法。 本章将介绍 字符串常量 （C 标准中称为 字符串字面量 ）和 字符串变量 （可以在程序运行时修改）。 一 字符串字面量 字符串字面量（string literal）是一对用双引号括起来的字符序列。 C++ 中常称为字符串字面值，或称为常值，或称为字面量。有些 C 语言的书中称之为字串 1. 字符串字面量中的转义序列 字符串字面量可以包含转义序列。比如： 虽然字符串字面量中的八进制数和十六进制数的转义序列也是合法的，但是字符转义序列更为常见。 注意：在字符串字面量中慎用八进制数和十六进制数的转义序列 八进制数的转义序列在三个数字后 或者 在第一个非八进制数字符 处结束。如： \\1234 包含两个字符 \\123 和 4 十六进制数的转义序列则不限制 3 个数字，而是直到第一个非十六进制数字符处结束。如： Z\\xfcrich 表示 6 个字符（ Z ， \\xfc ， r ， i ， c ， h ） 十六进制转义序列的通常范围是 \\x0 ~ \\xff ，所以 \\xfcber 本应是两个字符( \\xfcbe , r )但是这个十六进制数对字符来说太大了。 2. 延续字符串字面量 如果发现字符串字面量太长而无法放置在单独一行内，有下面几种办法解决这个问题。这个问题在前面详细讲过了，有兴趣可以看我之前的文章。下面我就不细说了。 使用 \\ 但是字符串字面量必须从下一行最左边继续，这样会破坏程序的缩进结。 3. 字符串字面量的存储 本质上而言，C 语言把字符串字面量作为字符数组来处理。当 C 语言编译器在程序中遇到了长度为 n 的字符串字面量时，它会为字符串字面量分配长度为 n + 1 的内存空间。额外的 1 个空间用来存放一个 空字符 来标识字符串末尾。空字符是所有位都为 0 的字节，因此用转义序列 \\0 来表示。 注意：不要混淆空字符 '\\0' 和零字符 '0' 。 '\\0' 的 ASCII 码值为 0； '0' 的 ASCII 码值为 48 \"abc\" 使用 4 个字符的数组来存储的： 字符串字面量可以为空： \"\" 表示单独存储一个空字符 既然字符串字面量是作为数组来存储的，那么编译器会把它看作是 char* 类型的指针。 printf 和 scanf 函数都接收 char* 类型的值作为它们的第一个参数。思考下面的例子： 当调用 printf 函数时，会传递 \"abc\" 的地址。（即指向存储字母 a 的内存单元的指针） 4. 字符串字面量的操作 通常情况下，可以在任何C语言允许使用 char* 指针的地方使用字符串字面量。例如，字符串字面量可以出现在赋值运算符的右边。 这个操作不是复制 \"abc\" 中的字符，而是使 p 指向字符串的第一个字符。 C 语言允许对指针取下标，所以可以对字符串字面量取下标： ch 的新值是 'b' 。甚至可以： 字符串字面量的这种特性并不常用，但有时也很方便：这个函数将 0 ~ 15 的数转换成等价的十六进制的字符形式： 注意：试图改变字符串字面量会导致未定义行为 改变字符串字面量会导致程序崩溃或运行不稳定。 5. 字符串字面量与字符常量 只包含一个字符的字符串字面量不同于字符常量 。字符串字面量 \"a\" 是用 指针 来表示的，这个指针指向存放字符\"a\"（后面紧跟空字符）的内存单元。字符常量 'a' 是用 整数 （字符集的数值码）来表示的。 注意：不要再需要字符串的时候使用字符（反之亦然） 函数调用： 是合法的。然而使用字符则是非法的： 二 字符串变量 一些编程语言专门为声明字符串变量提供了专门的 string 类型。C 语言采用了不同的方式：只要保证字符串是以空字符结尾的，任何一维的字符数组都可以用来存储字符串。 假设需要用一个变量来存储最多有 80 个字符的字符串。由于字符串末尾有空字符，我们需要声明含有 81 个字符的数组： 这里把 STR_LEN 定义为 80 而不是 81，强调的是 str 最多可以存储 80 个字符；然后才在 str 的声明中对 STR_LEN 加 1 。这是 C 程序员常用的方式。 注意：声明用于存放字符串的数组时，要始终保证数组长度比字符串长度多一个字符 这是因为 C 语言规定每个字符串都已 \\0 结尾。如果没有空字符预留位置，可能导致运行时出现未定义行为。因为C函数库中的函数假设字符串都以空字符结尾。 声明长度为 STR_LEN + 1 的字符数组并不意味着总是存放长度为 STR_LEN 的字符串。字符串长度取决于 \\0 出现的位置。 1. 初始化字符串变量 字符串变量可以在声明时进行初始化： 编辑器将把字符串 \"June 14\" 中的字符复制到数组 data1 中，然后追加一个空字符： \"June 14\" 看起来像是字符串字面量，但其实不然。C 编译器会把它看成是数组初始化式的缩写形式。实际上我们可以写成： 不管是编写还是阅读，后者都不是好的选择。使用数组的初始化式时，切记要手动 加上 '\\0' 如果初始化式太短以致于不能填满字符串变量将会如何呢？在这种情况下，编译器会 添加空的字符 。因此，在声明： 之后，data2 将如下图所示： 如果初始化式比字符串变量长会怎样？这对字符串而言是非法的，就如同对数组而言是非法的一样。然而，C 语言允许初始化式（不包括空字符）与变量有完全相同的长度。 由于没有给空字符留出空间，所以编译器不会试图存储空字符。因此，data3 无法作为字符串使用。 字符串变量的声明中可以省略它的长度。这种情况下，编译器会自动计算长度： 编译器会为 date4 分配 8 个字符的空间。 如果初始化式很长，那么省略字符串变量的长度是特别有效的，因为手工计算长度很容易出错。 2. 字符数组与字符指针 前者声明 date 是一个字符数组，或者声明 date 是一个指针。 它们的相同点类似数组和指针，现在我们看一下不同点： 声明为数组，可以修改存储在 date 中的元素；声明为指针，date 指向字符串字面量，前面我们已经讲过字符串字面量是不能被修改的。 声明为数组，date 是数组名。声明为指针，date 是变量，这个变量可以在程序执行期间指向其他字符串。 如果我们希望可以修改字符串，那么应该建立字符数组存储字符串。 如果我们声明了一个 char* 类型的指针，在使用它之前应让它指向字符串字面量或者字符串变量。 注意：使用未初始化的指针变量作为字符串是严重的错误 这个程序试图创建一个字符串。因为 p 没有被初始化，所以我们不知道它指向哪里。直接解引用属于非法内存访问。 三 字符串的读和写 1. 用 printf 函数和 puts 函数写字符串 使用转换说明： %s 输出会是： printf 函数会逐个写字符串中的字符，直到遇到空字符为止。如果空字符丢失，printf 函数会越过字符串的末尾继续写，直到最终在内存的某个地方找到空字符为止。 如果只想显示字符串的一部分，可以使用转换说明 %.ps 这里 p 是要显示的字符数量。 字符串跟数一样，可以指定字段内显示。转换说明 %ms 会在大小为 m 的字段内显示字符串。（对于超过 m 个字符的字符串，printf 函数会显示整个字符串，而不会截断。）如果字符串少于 m 个字符，则会在字段内右对齐输出。如果要前置左对齐，可以在 m 前加一个 - 号。m 和 p 可以组合使用：转换说明 %m.ps 会使字符串的前 p 个字符在大小为 m 的字段内输出。 比如： 输出： 还可以使用 puts 函数输出字符串。 puts 函数只有一个参数，即需要显示的字符串。写完字符串后，puts 函数总会添加一个额外的换行符: 输出： puts 函数 int puts( const char *str ) 头文件： &#x3C;stdio.h> 参数： str - 要写入的参数 返回值： 成功时返回非负值 失败时，返回 EOF 并设置 stdout 的错误指示器 定义： 写入每个来自空终止字符串 str 的字符及附加换行符 ' \\n ' 到输出流 stdout ，如同以重复执行 putc 写入。 不写入来自 str 的空终止字符。 2. 用 scanf 函数和 gets 函数读字符串 转换说明 %s 在 scanf 函数调用中， 不需要 在 str 前加 &#x26; 运算符，因为 str 是数组名，编译器在把他传给函数时会把它当作指针来处理。 调用时，scanf 函数会 跳过空白字符 ，然后读入字符并存储到 str 中，直到遇到 空白字符 为止。scanf 函数始终会 在字符串末尾存储一个空字符 。 用 scanf 函数读入字符串永远不会包括空白字符。因此，scanf 函数通常不会读入一整行输入。换行符，空格符和制表符都会使 scanf 函数停止读入。为了一次读入一整行输入，可使用 gets 函数。 gets 函数 char * gets(char * str) head： &#x3C;stdio.h> Parameters： str - Pointer to a block of memory (array of char) where the string read is copied as a C string. Return Value： On success, the function returns str . Description： Reads characters from the standard input ( stdin ) and stores them as a C string into str until a newline character or the end-of-file is reached. The newline character, if found, is not copied into str . A terminating null character is automatically appended after the characters copied to str . 总结一下重点就是： gets 函数 不会 在开始读字符串之前跳过空白字符。 gets 函数会持续读入直到找到 换行符 才停止。 换行符会被忽略 ，不会存储到数组中，在字符串 末尾追加空字符 。 我们用程序来比较一下 scanf 和 gets ： 先来测试 scanf： 输出： 只有 \"Are\" 被存储到了 str 中 测试 gets： 输出： \"Are you ok?\" 一整行被存入 str 中 注意： 把字符读入数组时，scanf 函数和 gets 函数都无法检测数组何时被填满。因此，它们存储字符时可能会越过数组的边界，这会导致未定义行为。 通过转换说明 %ns 代替 %s 可以使 scanf 更加安全。这里 n 指出可以存储的最多字符数。可惜的是，gets 天生就是不安全的， fgets 函数则是好的多的选择（后面会讲）。 3. 逐个字符读取字符串 因为对许多程序而言，scanf 函数和 gets 函数都有风险而且不够灵活，C 程序员经常会自己编写输入函数。通过每次读一个字符的方式读取字符串。 如果决定自己设计输入函数，那么需要考虑以下问题： 在开始存储字符串之前，函数应该跳过空白字符吗？ 什么字符导致函数停止读取：换行符，任意空白字符，还是其他某种字符？需要存储这些字符还是忽略掉？ 如果输入的字符串太长以至于无法存储，那么函数应该忽略额外的字符还是把它们留给下一次输入操作？ 示例中，我们选择：不跳过空白字符，换行符结束，不存储换行符，忽略掉额外字符。 函数原型如下： 参数：str 表示存储输入的"},{"title":"预处理器","slug":"c-modern-approach/15-preprocessor","permalink":"/kb/posts/c-modern-approach/15-preprocessor","category":"c-modern-approach","description":"C语言预处理器指令：#define、#include、条件编译","date":"2026-06-16T00:00:00.000Z","content":"预处理器 Every program has (at least) two purposes: the one for which it was written and another for which it wasn’t. 1 目录 [TOC] 预处理器 零 前言 前面我们用到的 #define 和 #include 指令都是由 预处理器 处理的。预处理器是一个小软件，它可以在编译前处理 C 程序。C 语言（和 C++ 语言）因为依赖预处理器而不同于其他的编程语言。 预处理器是一个强大的工具，但它同时也可能是许多难以发现的错误的根源。尽管有些 C 程序员十分依赖于于预处理器，我依然建议适度使用，就像对待生活中的许多其他事物一样。 一 预处理器的工作原理 预处理器的行为是由 预处理指令 （由 # 字符开头的一些命令）控制的。 如图说明了预处理器在编译过程中的作用。 为了展示预处理器的作用，我们写一个 c 程序(.c 文件)，我们来看一下预处理后的文件(. i 文件)： VS 查看预处理后的文件方法 链接： https://blog.csdn.net/weixin_33708432/article/details/85824803 我们写一个程序： test.c 打开生成的 .i 文件（我的在 Debug 目录中），拉到结尾，看到下面的代码： test.i 文件部分代码： 我们可以发现，预处理器做了这些事情： 预处理器通过引入 stdio.h 的内容来响应 #define 指令，然后删除该指令 替换了该文件中稍后出现在任何位置上的 FREEZING_PT 和 SCALE_FACTOR 请注意预处理器并没有删除包含指令的行，而是简单地将它们替换为空 每一处注释都替换为一个空格字符 有一些预处理器还会删除不必要的空白字符，包括每一行开始用于缩进的空格符和制表符 在 C 语言较为早期的时期，预处理器是一个单独的程序，它的输出提供给编译器。如今，预处理器通常和编译器集成在一起。 二 预处理指令 宏定义： #define 指令为一个宏。 #undef 指令删除一个宏定义。 文件包含： #include 指令导致一个指定文件的内容被包含到程序中。 条件编译： #if ， #ifdef ， #ifndef ， #elif ， #else 和 #endif 指令可以根据预处理器可以测试的条件来确定是将一段文本块包含到程序中还是将其排除在程序之外。 剩下的： #error ， #line ， #pragma 指令是更特殊的指令，较少用到。 其中，文件包含指令会放到下一章节中介绍。 适用于所有指令的规则： 指令都以 # 开始 在指令的各部分之间可以插入任意数量的空格或水平制表符 指令总在第一个换行符处结束，除非明确地指明要延续 指令可以出现在程序中的任何地方 但我们通常放在程序的开始 注释可以和指令放在同一行 事实上，这样做是个好习惯： 三 宏定义 1. 简单的宏 简单的宏（C 标准中称为对象式宏） 替换列表可以包含标识符，关键字，数值常量，字符常量，字符串字面量，操作符和排列。 在宏后面的程序内容中，预处理器会用 替换列表 替换 标识符 注意： 不要在宏定义中放置任何额外的符号，否则它们会被作为替换列表的一部分。 宏定义中使用 = 结尾使用分号 ; 编译器可以检测到宏定义中绝大多数由多余符号所导致的错误。但是，编译器只会讲每一个使用这个宏的地方标为错误，而不会直接找到错误的根源——宏定义本身，因为宏定义已经被预处理器删除了。 简单的宏主要用来定义那些被 K,R 称为“明示常量”（manifest constant）的东西。比如： 使用 #define 来为常量命名由许多显著的优点： 程序会更加易读 帮助读者理解常量的含义，减少“魔法数”。 程序会易于修改 可以避免前后不一致或键盘输入错误 对 C 语法做小的修改 比如： 当然这样的做法可能会让别人难以阅读你的程序。 对类型重命名 但是要知道， 类型定义 仍然是定义新类型的最佳方法。 控制条件编译 注意： 宏定义中的替换列表为空是合法的 当宏作为 常量 使用时，C 程序员习惯在名字中只使用 大写字母 。 2. 带参数的宏 带参数的宏 （也称为 函数式宏 ） 比如： 如果程序中有如下语句： 预处理器会将这些行替换为： 如这个例子所示， 带参数的宏经常用来作为简单的函数使用 。 ctype.h 头文件中的 toupper 的一种实现： 带参数的宏也可以包含空的参数列表： 使用带参数的宏替代函数有 两个优点 ： 程序可能稍微快一些 宏更为通用 与函数不同，宏的参数没有类型。 但是带参的宏也有一些 缺点 ： 编译后的代码通常会变大 比如用 MAX 宏来找出三个数中的最大值： 下面是预处理后的语句： 宏参数没有类型检查 预处理器不会检查参数类型，也不会进行类型转换。 无法用指针指向宏 C 语言允许指针指向函数。因为宏在预处理过程中被删除，所以不存在指向宏的指针。 宏可能不止一次地计算它的参数 。 函数对它的参数只会计算一次，宏可能会计算多次。 预处理后： 如果 i 大于 j ，那么 i 可能会被（错误的）增加两次，同时 n 可能被赋予错误的值。 所以说，最好避免使用自增自减的参数 宏定义还可用于需要重复书写的代码段模式： 3. 宏的通用属性 宏的替换列表可以包含对其他宏的调用 预处理器只会替换完整的记号，而不会替换记号的片段 预处理后： 标识符 BUFFER_SIZE 和字符串字面量中的 SIZE 不会被替换 宏定义的作用范围通常到出现这个宏的文件末尾 由于宏是预处理器处理的，他不遵从通常的作用域规则。 宏不可以被定义两遍，除非新的定义与旧的定义是一样的 宏可以使用 #undef 指令“取消定义” 比如： 会删除宏 N 当前的定义。（如果 N 没有被定义成为一个宏，#undef 指令没有任何作用。）#undef 指令的一个用途是取消宏的现有定义，以便重新给出新的定义。 4. # 运算符 # 运算符将一个宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。 # 运算符有多种用途，这里只讨论一种。 我们讲这个宏改写为： 如果我们调用： 会变为： 我们知道，C 语言相邻的字符串字面量会被合并。因此上面的语句等价于： 下面是完整的程序演示： 输出： 5. ## 运算符 ## 运算符可以将两个记号（如标识符）“粘合”在一起，称为一个记号。 比如： 预处理后： ## 运算符并不属于预处理器最经常使用的特性。为了找到一种使用它的情况，我们思考之前我们定义的 MAX 宏。当 MAX 的参数中含有自增自减运算时无法正常工作。一种解决方法是写一个函数实现 MAX 的功能，遗憾的是，仅仅一个函数是不够的，我们可能需要实参是 int 类型的函数，也可能是 double，char 等等。这些函数的功能相似，按照参数类型再定义一个函数似乎比较蠢。 为了解决这个问题，我们用 ## 运算符为每一个版本的 max 函数构造不同的函数名以及参数类型。下面是宏的形式： 如果我们需要定义一个针对 float 类型的 max 函数： 预处理后： 下面是一段完整的程序： 6. 宏定义中的圆括号 对于一个宏定义中哪里要加圆括号有两条规则要遵守： 如果宏的替换列表中有运算符，那么要将替换列表放在圆括号中 如果宏有参数，每个参数每次在替换列表中出现时都要放在圆括号中 没有括号的话，编译器可能会不按我们预期的方式应用运算符的优先级和结合性。比如： 变为： 除法会在乘法之前执行（我们希望的是： 360 / (2 * 3.14159) ）。 变为： 加法会在乘法后执行（我们希望的是： (i + 1) * 10 ）。 7. 创建较长的宏 在创建较长的宏时，逗号运算符会十分有用。比如，下面的宏会读入一个字符串，然后再把它显示出来： 比如： 如果不想在 ECHO 的定义中使用逗号运算符，我们可以将函数调用放在花括号中形成复合语句： 需要注意的是，如果你这样写 if 语句： 替换后： 编译器会将跟在后面的分号作为空语句，并对 else 子句产生出错消息，因为它不属于 if 语句。所以，正确的写法如下： 但是这样做会使程序看起来有些怪异。 如果一个宏需要包含一系列的语句，而不仅仅是一系列表达式，这时逗号运算符就不起作用了，因为它只能连接表达式，不能连接语句。解决的办法很简单： 使用 ECHO 宏时，需要加上分号使 do 语句完整： 8. 预定义宏 名字 描述 __LINE__ 被编译的文件中的行号 __FILE__ 被编译的文件名 __DATE__ 编译的日期 __TIME__ 编译的时间 __STDC__ 如果编译器复合 C 标准，值为 1 比如： 输出： 我们可以使用 __LINE__ 和 __FILE__ 来找到错误，比如： 如果 j 是 0，程序会显示如下形式的信息： 类似这样的错误检测的宏非常有用。实际上，C 语言库提供了一个通用的，用于错误检测的宏 —— assert 宏 C99 中新增的 __func__ 标识符。它与预处理器无关。但是，与许多预处理特性一样，它也有助于调试。比如： __func__ 的另一个用法：作为参数传给函数，让它知道调用它的函数名。 四 条件编译 1. #if 指令和 #endif 指令 当预处理器遇到 #if 指令时，会计算常量表达式的值。如果表达式的值为 0 ，那么 #if 和 #endif 之间的行将在预处理过程中从程序中删除；否则，#if 和 #endif 之间的行会被保留，继续留给编译处理——#if 和 #endif 会在预处理中被删除。 如果将宏 ISPRINT 定义为 0 ，程序执行结果不会输出 1 #if 指令会把没有定义过的标识符当作是值为 0 的宏对待，所以如果省略 ISPRINT 的定义，测试： 会失败，而测试： 会成功 2. defined 运算符 当 defined 引用于标识符时，如果标识符是一个定义过的宏则返回 1，否则返回 0 。 defined 运算符通常与 #if 指令结合使用。 可以写作： 仅当 ISPRINT 被定义为宏时，保留中间的代码。也可以去掉 宏 两边的括号： 由于 defined 运算符仅检测 ISPRINT 是否有定义，所以不需要给 ISPRINT 赋值： 3. #ifdef 指令和 #ifndef 指令 #ifdef 指令测试一个标识符是否已经定义为宏： 其实这和 是等价的 #ifndef 指令测试的是标识符是否没有被定义为宏： 等价于： 4. #elif 指令和 #else 指令 #elif 和 #else 可以和 #if ， #ifdef ， #ifndef 结合使用： 其实 #elif 像 else if ，整体结构和层级式 if 语句十分相似 5. 使用条件编译 编写在多台机器或多种操作系统之间可移植的程序 比如： 定义 LINUX 宏可以指明程序将运行在 linux 操作系统下。 编写可以用不同的编译器编辑的程序 比如： 为宏提供默认定义 检测一个宏当前是否已经被定义了，如果没有提供一个默认定义。例如： 临时屏蔽包含注释的代码 我们不能用 /* ... */ 直接注释掉已经包含 /* ... */ 的代码。然而，我们可以用 #if 指令来实现： 将代码以这种方式屏蔽掉经常称为“条件屏蔽”。 下一节我们会讨论条件编译的另一个用途： 保护头文件以避免重复包含 五 其他指令 1. #error 指令 比如： 如果在一台 16 位存储整数的机器上运行这个程序，将会产生一条出错提示： 但是 VS 2019 编译器会直接提示错误： #error 通常出现在 #if-#elif-#else 序列中的 #else 部分： 2. #line 指令 只指定行号： 这条指令导致程序中后续的行被编号为：n, n + 1, n + 2, ... 指定行号和文件名： 指令后面的行会被认为来自文件，行号由 n 开始。n 和 文件字符串 可以用宏指定。 #line 的"},{"title":"编写大型程序","slug":"c-modern-approach/16-large-programs","permalink":"/kb/posts/c-modern-approach/16-large-programs","category":"c-modern-approach","description":"If a listener nods his head when you're explaining your program, wake him up. [^1]","date":"2026-06-16T00:00:00.000Z","content":"编写大型程序 If a listener nods his head when you're explaining your program, wake him up. 1 目录 [TOC] 编写大型程序 零 源文件 到现在为止一直假设 C 程序是由单独一个文件组成的。事实上，可以把程序分割为任意数量的 源文件 。根据惯例，源文件的扩展名为 .c 。每个源文件包含程序的部分内容，主要是函数的定义和变量的定义。其中源文件必须包含一个 main 函数作为程序的起始点。 把程序分为多个源文件有许多 优点 ： 把相关的函数和变量分组放在同一个文件中可以使程序的结构清晰 可以分别对每一个源文件进行编译 把函数分组放在不同的源文件中更利于复用 一 头文件 当把程序分割为几个源文件时，问题也随之产生了：某文件中的函数如何调用定义在其他文件中的函数呢？函数如何访问其他文件中的外部变量呢？两个文件如何共享一个宏定义或类型定义呢？答案就是使用 #include 指令 #include 指令告诉预处理器打开指定的文件，并把此文件的内容插入到当前文件中。按照此种方式包含的文件称为 头文件 （有时称为包含文件）。 注意：C 标准使用术语“源文件”来只是程序员编写的全部文件，包括 .c 和 .h 文件。前面说的源文件指 .c 文件。 0. #include 指令 #include 指令主要有两种书写格式，这两种格式之间的差异在于编译器定位头文件的方式。 用于属于 C 语言自身库的头文件： 搜寻系统头文件所在的目录（或多个目录）。 用于所有其他头文件（包含自己编写的）： 先搜寻当前目录，然后再搜寻系统头文件所在的目录。 在 #include 指令中的文件名可以含有帮助定位文件的信息，比如目录的路径或驱动器号： 注意： 预处理器不会将 #include 指令中的双引号部分当作字符串字面量来处理。（否则，上例中的 \\v 和 \\m 会被当作转义序列处理） 最好在 #include 指令中不包含路径和驱动器信息（相对路径要好于绝对路径），这样可以提高可移植性。 #include 指令还有一种格式： 应用场景： 2. 共享宏定义和类型定义 3. 共享函数原型 4. 共享变量声明 上面这三部分的内容本质上差不多，后面我们会用一个程序来向大家演示如何完成。 需要注意的是： 含有函数和变量定义的 .c 文件需要包含有相应声明的头文件，这样编译器可以检查声明与定义是否匹配。 在头文件中这样写： 这样不仅声明 i 是 int 类型变量，而且也对 i 进行了定义，从而使编译器为 i 留出了空间。为了声明变量 i 而不是定义它，可以这样做： extern 告诉编译器，变量 i 是在程序中的其他位置定义的，因此不需要为 i 分配空间。 extern 可以用于所有类型变量的声明中。在数组的声明中使用 extern 时，可以省略数组长度： 因为此刻编译器不用为数组分配空间，所以不需要知道数组长度。 说了这么多我是没看懂 extern 怎么用，反正用的不多，不懂没事。 5. 嵌套包含 6. 保护头文件 如果源文件包含同一个头文件两次，那么可能产生编译错误。 比如 file1.h 包含 file3.h ，file2.h 包含 file3.h 如果 foo.c 同时包含 file1.h 和 file2.h，那么 file3.h 会被该源文件包含两次。（头文件包含另一个头文件就是所谓的嵌套包含，简单吧。） 保护头文件的 好处 ： 安全 减少重复，提高效率 比如： 在 boolean.h 中定义宏 BOOLEAN_H ,首次包含这个头文件时，该宏没有被定义。另外，这种情况下，这样定义宏是一个不错的选择。 7. 头文件中的 #error 指令 #error 指令经常放置在头文件中，用来检查不应该包含该头文件的条件。例如：如果一个头文件中用到了一个在最初的 C89 前不存在的特性，为了避免把头文件用于旧的非标准编译器，检查 __STDC__ 宏是否存在： 二 把程序划分成多个文件 程序：文本格式化 输入未格式化的引语：来自 Dennis M. Ritchie 写的\"The Development of the C programming language\" 一文： 程序完成对这段文字的调整： 程序分析： 完成这个程序需要两步：读入和输出。 读入我们选择按单词读入到当前行中，然后按当前行输出。注意输出的每一行最后“对”的很齐，我们 write_line 函数对这种格式做了特殊处理。 按单词读入我们创建 word.h 和 word.c 按行输出我们创建 line.h 和 line.c 最后用 justify.c 包含 main 函数 参考程序： word.h line.h word.c line.c justify.c 三 构建多文件程序 编译 必须对程序中的每个源文件分别进行编译。（ 不需要编译头文件。 编译包含头文件的源文件时会自动编译头文件的内容。）对于每个源文件，编译器会产生一个包含目标代码的文件。这些文件称为 目标文件 （object file），在 UNIX 中扩展名为 .o ，Windows 中为 .obj 链接 连接器把上一步产生的目标文件和库函数的代码结合起来在一起生成可执行的程序。链接器的一个职责是解决编译器遗留的外部引用问题。（外部引用发生在一个文件中的函数调用另一个文件中定义的函数或访问另一个文件中定义的变量时。） 编译我们可以用命令： gcc -c 文件名 大多数编译器允许一部构建： gcc -o justify justify.c word.c line.c 选项 -o 表明我们希望的可执行文件名为： justify 0. makefile makefile 过于复杂，以后可能会单独处一起教学。 1. 链接期间的错误 如果程序丢失了函数的定义或变量定义，那么链接器将无法解析外部引用，从而导致 undefined symbol 或 undefined referece 的消息。 下面是一些最常见的错误起因： 变量名或函数名拼写错误 。 缺失文件 如果编译器不能找到 foo.c 中的函数，那么可能不知道此文件。需要检查是否列出了 foo.c 文件 缺失库 链接器不可能找到程序中用到的全部库函数。Linux/Unix 中使用头 &#x3C;math.h> 可能需要在链接程序时指明选项 -lm ，这会导致链接器去搜索一个包含&#x3C;math.h>函数编译版本的系统文件。（命令为 gcc -lm 文件名 ） 2. 重新构建程序 程序开发期间，极少需要编译全部文件。为了节约时间，重新构建的过程应该只对那些可能受到上次修改影响的文件进行重新编译。 需要重新编译的文件有两种可能性： 源文件被改 源文件包含的头文件被改 比如我们需要对 程序：文本格式化 中的程序做出一些修改： 修改 word.c 中的 read_char 函数： 为了避免在 justify.c 中使用 strlen ，我们可以修改 word.c 中的 read_word 函数的返回值： 与此同时，我们需要改变 read_word 在 word.h 中的声明： 然后改变 justify.c 函数对 read_word 的调用： 如此一来，我们改变了 word.c , word.h 和 justify.c ，在重新构建可执行程序 justify 时，我们需要重新编译 word.c 和 justify.c 然后再重新链接。注意，我们不需要重新编译 line.c ,因为它没有被修改也没有包含 word.h 。所以，对于 GCC 编译器，可以使用下面的指令进行重构： gcc -o justify justify.c word.c line.o 3. 在程序外定义宏 命令： gcc -D 比如： 其效果相当于在 foo.c 的开始处这样写： 如果 -D 选项没有指定值，那么这个值被设为 1 许多编译器也支持 -U 选项，用于删除宏，效果相当于 #undef 参考资料：《C语言程序设计：现代方法》 Footnotes 如果有人听你讲解程序时点头了，把他叫醒。 Epigrams on Programming 编程警句 ↩"},{"title":"结构&联合&枚举","slug":"c-modern-approach/17-structs-unions-enums","permalink":"/kb/posts/c-modern-approach/17-structs-unions-enums","category":"c-modern-approach","description":"A program without a loop and a structured variable isn't worth writing. [^1]","date":"2026-06-16T00:00:00.000Z","content":"结构&#x26;联合&#x26;枚举 A program without a loop and a structured variable isn't worth writing. 1 目录 结构&#x26;联合&#x26;枚举 零 前言 可以参考学习的文章： https://mp.weixin.qq.com/s/NkXZSdM-gnAuG7_jAM8ZiA 一 结构变量 前面我们说过数组有两个重要特性： 数组所有的元素具有相同的数据类型 选择数组元素需要指明元素的位置（下标） 结构和数组有很大不同。结构的元素（C 语言中的说法是 成员 ）可以具有不同类型。而且每个结构成员都有名字，访问结构体成员需要指明结构成员的名字而不是位置。 在一些编程语言中，经常把结构体称为 记录 （record），把结构体的成员称为 字段 （field）。 0. 结构变量的声明 假如需要记录存储在仓库中的零件。我们可能需要记录零件的编号，名称和数量。我们可以使用结构体： struct{...} 指明类型， part1,part2 是这种类型的变量。 结构体在内存中是按照 声明顺序 存储的。 至于细化到字节，结构体是否也是紧挨着存储的，这里我们可以留个悬念，大家自行猜测一下。（如果你想了解，可以参考文章： https://mp.weixin.qq.com/s/uG1ZNWbmXAYPL4Rs4uqoKQ） 1. 结构变量的初始化 我们可以在定义结构体的同时初始化： 初始化式中的值必须按照结构体成员的顺序进行显示。 结构初始化式遵循的原则类似于数组的。初始化式必须是常量（C99 中允许使用变量）。初始化式中的成员可以少于它所初始化的结构，“剩余的”成员用 0 作为初始值。特别的，剩余的字符串应为空字符串。 2. 指定初始化（C99） 特性和数组一样，比如： number 被默认为 0， .name 直接跳过 number 初始化 name，123 初始化的成员为 .name 后一个成员。 3. 对结构的操作 访问成员 方式如下： 结构的成员是左值，所以可以出现在赋值运算的左侧： . 其实就是一个 C 语言的运算符。 . 运算符的优先级几乎高于所有其他运算符，所以思考： &#x26; 计算的是 part1.on_hand 的地址 赋值运算： 等价于： 如果这个结构内含有数组，数组也会被复制。 但是不能使用 == 和 != 运算符判定两个结构是否相等。 二 结构类型 如果我们要在程序的不同位置声明变量，我们就需要定义表示一种结构类型的名字。 试思考： 在程序的某处，为了描述一个零件，我们写了上面的代码。但是，现在在程序的另一处有需要一个零件，直接增加一个变量： 这种方式固然可行，但是有些“呆”。 那么，如果我们再次定义一个相同的“零件类型”： 请注意： part1 和 part2 具有不同的类型 0. 结构标记的声明 结构标记 （struct tag）用来标识某一种特定的结构名称。下面的例子声明了名为 part 的结构类型： 注意：花括号后的分号不可少 如果忽略了分号，可能回 得到含义模糊的出错信息 ，比如： 由于前面的结构声明没有正常终止，所以编译器会假设函数 f 返回值是 struct part 类型的，所以直到 f 中的第一条 return 语句才会发现错误。 声明变量： 注意：不能省略 struct 也因为结构标记只有在 part 前放置 struct 才有意义，所以声明名为 part 的变量是完全合法的。（但是容易混淆） 声明结构标记和结构变量可以放在一起： 所有声明为 struct part 类型的结构彼此兼容。 1. 结构类型的定义 使用 typedef 定义名为 part 的结构类型： 如此，我们就可以像上面那样声明结构变量： 因为类型名为 part 所以书写 struct part 是不合法的。 如果你也想可以使用 struct part ，那你可以这样声明： 2. 结构作为参数和返回值 结构作为参数 函数： 调用方式： 结构作为返回值 函数： 调用方式： 给函数传递结构和从函数返回结构都需要生成结构所有成员的副本，这回可能会产生一定数量的系统开销。为了避免这种开销， 常传递或返回指向结构的指针来代替传递或返回结构本身 。下一节中，我们将会看到这样的应用。 3. 复合字面量（C99） 略。 三 嵌套的结构和结构数组 0. 嵌套的结构 把一种结构嵌套在另一种结构中经常是非常有用的。比如： 定义一个结构存储一个人的姓名： 定义一个结构存储学生信息： 访问 student1 的名和姓需要应用两次 . : 1. 结构数组 声明一个数组用来存储 100 个零件信息： 访问零件数组中下标为 i 的元素的结构成员： 使存储在零件数组中下标为 i 的元素的姓名变为空字符串，可以写成： 2. 结构数组的初始化 初始化结构数组与初始化多维数组的方法非常相似。比如： 与数组一样，指定初始化（C99）也适用于这种情况。 程序：维护零件数据库 此程序用来维护仓库存储的零件信息的数据库。程序围绕一个结构数组构建，且每个结构包含以下信息：零件编号，名称和数量。程序将支持下列操作： 添加新零件信息 。如果零件已经存在，或数据库已满，显示出错信息。 给定零件编号，显示零件的名称，数量信息 。如果零件编号不存在，那么给出出错信息。 给定零件编号，改变零件的数量 。如果零件编号不存在，给出出错消息。 显示列出数据库中的全部信息 。零件必须按照录入顺序显示。 终止程序的执行 使用： i :插入 s :搜索 u :更新 p :显示 q :退出 分表表示这种操作，与程序得到会话如下： 注意：菜单可以没有 因为 readline 函数和这个程序的主干没有太大关系，我们用单独的头文件和源文件包含它。 readline.h readline.c inventory.c 四 联合 像结构一样， 联合 （union）也是由一个或多个成员构成，而且这些成员可以具有不同的类型。但是，编译器只为联合中最大的成员分配足够的空间。联合的成员在这个空间内彼此覆盖，给一个成员赋予新值也会改变其他成员的值。 结构变量 s 和 联合变量 u 只有一处不同：s 的成员存储在 不同 的内存地址中；u 的成员存储在 同一 内存地址中。如图： 如果把一个值存储到 u.d 中，那么先前存储在 u.i 中的值会丢失。类似的，改变 u.i 也会影响 u.d 。 联合的性质几乎和结构一样。 联合的初始化方式和结构也很相似，但是，只有联合的第一个成员可以获得初始值。例如，如下初始化方式可以使得联合 u 的成员 i 的值为 0： 注意：花括号是必需的 。 指定初始化（C99）： 只能初始化一个成员，不一定是第一个。 0. 使用联合节省空间 有三种商品，每种商品都有库存，价格；这些商品还具有以下其他特性： 书籍：书名，作者，页数 杯子：设计 衬衫：设计，可选颜色，可选尺寸 假如我们设计包含上面特性的结构： item_type 的值是 BOOK,MUG,SHIRT 之一。 上面这种结构体比较浪费空间，因为对于某种特定商品，结构中只有部分字段是有用的。（当然你也可以定义三个结构体，我也建议这么做。） 现在我们引用联合： 书籍名称可以用以下方式显示： 把值存储在联合的一个成员中，然后访问另一个成员通常是不可取的。但是，如果联合的两个或多个成员是结构，而且这些结构最初的一个或多个成员是匹配的（顺序相同，类型兼容，名字可以不一样）。如果当前某个结构有效，其他结构中的匹配成员也有效。 联合 item 中，mug 和 shirt 第一个字段是匹配的。比如，如果我们给 mug 的成员 design 赋值： 结构 shirt 的第一个成员也具有相同的值： 1. 使用联合构造混合的数据结构 假设需要数组元素是 int 值和 double 值的混合。因为数组元素必须是相同类型，我们可以应用联合数组： 2. 为联合添加“标记字段” 联合面临的主要问题是：不容易确定联合最后改变的成员，因此对联合成员的访问可能是无意义的。 前面程序中 item_type 就是标记字段，用来帮助我们确定当前商品种类。 为了记录这种信息，我们可以把联合嵌入一个结构中，此结构还有另一个成员：“标记字段”或者“判别式”，用来提示当前存储在联合中的内容。比如定义如下结构： 当需要访问存放在联合中的成员时，可以使用函数： 注意：每次对联合成员赋值，都需要由程序改变标记字段的内容 五 枚举 C 语言为具有可能值较少的变量提供了一种专用类型 —— 枚举类型 （enumeration type） 定义扑克花色： CLUBS 的值为 0，DIAMAND 值为 1，后面的每个增加 1 ，以此类推。 如果没有枚举类型，我们需要一个个的来 #define 这样无疑会增加程序的复杂度，也会降低同种情况的联系，让程序变得难以阅读。 0. 枚举类型声明 1） 2） C89 中，使用枚举创建布尔类型： 如果要使用枚举变量： 枚举类型的变量可以赋值为任意枚举列出来的枚举常量。但是枚举常量可以赋值给普通整型变量，普通整型变量也可以赋值给枚举类型的变量。这是因为 C 语言对于枚举和整数的使用比较混乱，没有明确界限。 1. 枚举作为整数 在系统内部，C 语言会把枚举变量和常量作为整数来处理。默认情况下，编译器将 0，1,... 赋值给枚举常量。 我们可以为枚举常量自由选择不同的值。现在假设希望用 1 到 4 代表牌的花色，我们可以这样定义： 我们知道后一个枚举常量比前一个大 1，所以，我们也可以简化为： 也可以换为任意整数： 2. 使用枚举声明“标记字段” 现在我们可以不用宏的值来表示标记字段的含义了： 参考资料：《C语言程序设计：现代方法》 Footnotes 没有循环和结构变量的程序不值得写。 Epigrams on Programming 编程警句 ↩"},{"title":"指针的高级应用","slug":"c-modern-approach/18-advanced-pointers","permalink":"/kb/posts/c-modern-approach/18-advanced-pointers","category":"c-modern-approach","description":"指向函数的指针、动态内存分配与链表等高级指针应用","date":"2026-06-16T00:00:00.000Z","content":"指针的高级应用 A language that doesn't affect the way you think about programming, is not worth knowing. 1 目录 [TOC] 指针的高级应用 零 前言 相关文章参考： https://mp.weixin.qq.com/s/9nXO9i8AXbMZ5fyckLjp5A https://mp.weixin.qq.com/s/FfNI5ooT75VyIdM9dmiq-A 参考这两篇文章对你理解这部分知识很有帮助。 一 动态存储分配 C 语言的数据结构通常是固定大小的。例如，一旦程序完成编译，数组元素的数组就固定了。（C99 中，变长数组的长度在运行时确定，但是数组的声明周期内仍然是固定长度的。）因为在编写程序时强制选择了大小，在不修改程序并且再次编译程序的情况下无法改变数据结构的大小。 为了扩大数据结构（前面我们通常用到的是数组）的大小，可以增加数组大小并重新编译程序。但是，无论如何增大数组，始终有可能填满数组。幸运的是，C 语言支持 动态存储分配 ，即在程序执行期间分配内存单元的能力。利用动态存储分配，可以设计出根据需要扩大（和缩小）的数据结构。 0. 内存分配函数 为了动态地分配存储空间，需要调用三种内存分配函数的一种，这些函数都是声明在头 &#x3C;stdlib.h> 中的。 malloc 函数 —— 分配内存块，但是不对内存块进行初始化 calloc 函数 —— 分配内存块，并对内存块进行清零 realloc 函数 —— 调整先前分配的内存块的大小 这三种函数中， malloc 函数是最常用的。因为 malloc 不需要对分配的内存块进行清零，所以它比 calloc 函数 效率更高 。 当为申请内存块而调用内存分配函数时，由于函数无法知道计划存储在内存块中的数据是什么类型的，所以它不能返回 int 类型， char 类型等普通类型的指针。取而代之的是，函数返回 void* 类型的值。 void* 类型的值是“通用”指针，本质上它只是内存地址。 1. 空指针 当调用内存分配函数中时，总存在这样的可能性：找不到满足我们需要的足够大的内存块。如果真的发生了这类问题，函数会返回 空指针 （null pointer）。空指针是“不指向任何地方的指针”，这是一个区别于所有有效指针的特殊值。 **注意：试图通过空指针访问内存的效果是未定义的，程序可能出现崩溃或者出现不可预测的行为。**因此，在把内存分配函数的返回值存储到指针变量中以后，需要判断该指针变量是否为空指针。 空指针用名为 NULL 的宏来表示，所以可以使用下列方式测试 malloc 函数的返回值： 一些程序员把 malloc 函数的调用和 NULL 的测试组合起来： 名为 NULL 的宏在 6 个头 &#x3C;locale.h> &#x3C;stddef.h> &#x3C;stdio.h> &#x3C;stdlib.h> &#x3C;string.h> &#x3C;time.h> 中都有定义。 语句： 可以写成： 而语句： 可以写成： 二 动态分配字符串 0. 使用 malloc 函数为字符串分配内存 malloc 函数具有如下原型： size_t 是 C 语言库定义的无符号整数类型，除非分配的空间巨大，否则可以用 int 型。 为长度为 n 的字符串分配内存空间： n + 1 为空字符留出空间。执行赋值操作时会把 malloc 函数返回的通用指针转化为 char* 类型，而不需要强制类型转换。然后，一般我们都会进行强制类型转换： 注意：为字符串分配内存空间时，不要忘记包含空字符的空间 1. 在字符串函数中使用动态存储分配 我们自行编写一个函数将两个字符串连接起来而不改变其中任何一个字符串。先调用 malloc 分配适当大小的内存空间。接下来函数把第一个字符串复制到新的内存空间中，然后调用 strcat 函数来拼接第二个字符串： 如果 malloc 函数返回 NULL，函数显示出错信息并终止程序。这并不是正确的措施。 下面时可能的 concat 函数调用方式： 这个调用后，p 将指向字符串\"abcdef\"，此字符串存储在动态内存分配的数组中。数组包含结尾的空字符一共 7 个字符长。 注意：注意最后调用 free 函数释放申请的空间 3. 动态分配字符串的数组 程序：显示一个月的提醒列表 前面我们把字符串存储在二维数组中，但是这可能会浪费空间。后面的教学中我们设想使用指针数组存储字符串，让一维数组的每个元素都指向一个字符串字面量。如果数组元素是指向动态分配的字符串的指针，那么是可以实现我们的设想的。 下面的程序对之前的程序作了小部分修改，修改的地方后面用注释注明了。 三 动态分配数组 当编写程序时，常常很难为数组估计合适的大小。前面我们是用宏来定义数组的大小；现在我们可以在程序执行期间为数组动态分配内存空间。 0. 使用 malloc 函数为数组分配存储空间 分配一个 int[n] 大小的数组： 对 a 指向的数组进行初始化： 1. calloc 函数 函数原型： 下面 calloc 函数调用为 n 个整数的数组分配存储空间，并且初始化所有整数为 0： 调用以 1 作为第一个实参的 calloc 函数，可以为任何类型的数据分配空间： 执行完此语句后，p 将指向一个结构，且此结构的成员 x 和 y 都会被设为 0 。 2. realloc 函数 一旦为数组分配完内存，稍后可能会发现数组过大或过小。realloc 函数可以调整数组的大小使它更适合需要。 函数原型 当调用 realloc 函数时，ptr 必须指向先前通过 malloc，calloc 或 realloc 的调用获得的内存块。size 表示内存块的新尺寸，新尺寸可能大于或小于原有尺寸。 注意：要确定传递给 realloc 函数的指针来自于先前 malloc，calloc 或 realloc 的调用。如果不是这样的指针，程序可能会行为异常 C 标准列出了几条关于 realloc 函数的规则： 当扩展内存块时，realloc 不会对添加进内存块的字节进行初始化 如果 realloc 函数不能按要求扩大内存块，那么它会返回空指针，并且原有的内存块中的数据不会发生改变 如果 realloc 函数调用时以空指针作为第一个参数，那么它的行为就像 malloc 函数一样 如果 realloc 函数被调用时以 0 作为第二个实参，那么它会释放掉内存块 如果无法扩大内存块（因为内存块后面的字节已经用于其他目的），realloc 函数会在别处分配新的内存块，然后把旧块中的内容复制到新块中。 注意：一旦 realloc 函数返回，请一定要对指向内存块的所有指针进行更新，因为 realloc 函数可能会使内存块移动到了其他地方。 四 释放存储空间 动态存储分配函数所获得的内存都来自一个称为 堆 （heap）的存储池。过于频繁地调用这些函数（或者让这些函数申请大内存块）可能会耗尽堆，这回导致函数返回空指针。 更糟的是，程序可能分配了内存块，然后又丢失了对这些块的记录，因而浪费了空间。请思考下例： 如图： 因为没有指针指向第一个内存块，所以再也不能使用此块内存了。 对于程序而言，不可再访问到的内存称为 垃圾 （garbage）。留有垃圾的程序存在 内存泄漏 （memory leak）现象。一些语言提供 垃圾收集器 （garbage collector）用于垃圾的自动定位和回收，但是 C 语言不提供。所以我们使用 free 函数来释放不需要的内存。 0. free 函数 函数原型： 使用 free 函数很简单，将指向不再需要的内存块的指针传递给 free 函数即可： 调用 free 函数会释放 p 指向的内存块。然后此内存块可以被后续的 malloc 函数或其他内存分配函数的调用重新使用。 注意： free 函数实参必须是先前由内存分配函数返回的指针，如果参数是指向其他对象的指针，可能会导致未定义行为。 实参可以空指针，此时 free 调用不起作用 1. “悬空指针”问题 虽然 free 函数允许收回不再需要的内存，但会导致一个新的问题： 悬空指针 （dangling pointer）。调用 free(p) 函数会释放 p 指向的内存块，但是不会改变 p 本身。如果忘记了 p 不再指向有效内存块： 修改 p 指向的内存是严重的错误，因为程序不再对此内存由任何控制权了。 注意：试图访问或修改释放掉的内存块会导致未定义行为。 五 链表 链表这部分请参考【数据结构轻松学】部分。 程序：维护零件数据库 下面重做前面的程序，这次把数据库存储在链表中。链表代替数组主要有两个好处： 不需要事先限制数据库的大小 可以很容易地按零件编号对数据库排序（本程序采用默认升序排序） 六 指向指针的指针 前面我们使用元素类型为 char* 的数组，指向数组元素的指针的类型为 char** 。下面我们以链表的头插为应用场景，帮助大家了解指向指针的指针应该如何应用。 我们知道，链表的头插需要改变头指针。传递给函数的 list 为头指针（指向首节点的指针），函数返回指向新的首结点的指针。 假如我们将 return 语句删除，然后添加下面的语句： 可惜的是，这个想法无法实现。假设以此方法调用函数 add_to_list： 在调用点，会把 first 复制给 list 。（像所有其他参数一样，指针也是按值传递的。）函数最后一行改变了 list 的值，使它指向了新的结点。但是此复制操作对 first 没有影响。 让函数修改 first 是可能的，但是需要给函数 add_to_first 传递一个指向 first 的指针。下面是函数的正确形式： 调用此函数，第一个实参为 first 的地址： 使用 *list 作为 first 的别名，修改它是可以改变 first 的内容的。 七 指向函数的指针 指向函数的指针（函数指针），不像人们想的那么奇怪。毕竟函数占用内存单元，所以每个函数都有地址，就像每个变量都有地址一样。 参考资料：《C语言程序设计：现代方法》 Footnotes 没有影响你思考编程的语言不值得学。 Epigrams on Programming 编程警句 ↩"},{"title":"声明","slug":"c-modern-approach/19-declarations","permalink":"/kb/posts/c-modern-approach/19-declarations","category":"c-modern-approach","description":"C语言声明的语法结构、复杂声明解读与typedef","date":"2026-06-16T00:00:00.000Z","content":"声明 Wherever there is modularity there is the potential for misunderstanding: Hiding information implies a need to check communication. 1 目录 [TOC] 声明 零 前言 声明在 C 语言编程中起着核心的作用。通过声明变量和函数，可以检查程序潜在的错误以及把程序翻译成目标代码两方面为编译器提供至关重要的信息。 一 声明的语法 声明说明符 （declaration specifier）：描述声明的变量或函数的性质。 声明符 （declarator）给出了它们单独名字，并且提供了关于其他性质的额外信息。 声明说明符分为以下 3 大类： 存储类型 。 auto , static , extern 和 register 。在声明中最多可以出现 一种 存储类型。 类型限定符 。C89 ： const , volatile 。C99： restrict 。声明可以包含 多个 类型限定符。 类型说明符 。关键字 int, double, char 等。类型说明符也包含结构，联合和枚举。 C99 中还有一种说明符， 函数说明符 ，它只用于函数声明。这类说明符只有一个： inline 声明符包括 标识符 ，可以组合 * , [] , () 一起看一些说明这些规则的例子： 二 存储类型 这一部分集中讨论 变量 的存储类型。 块 （block），表示函数体或者复合语句（可以理解为使用花括号的地方）。C99 中，选择语句和循环语句也被视为块，尽管本质上有些区别。 1. 变量的性质 C 程序中的每个变量都具有以下 3 个性质： 存储期限 。变量的存储期限决定了为变量预留和内存被释放的时间。 自动存储期限 ：变量在所属块被执行时获得内存单元，并在程序终止时释放内存单元，从而导致变量失去值。 静态存储期限 ：程序运行期间占有同一个的存储单元，也就允许变量无限期地保留它的值。 作用域 。变量的作用域是指可以引用变量的那部分程序文本。 块作用域 ：变量从声明的地方一直到所在块的末尾都是可见的。 文件作用域 ：变量从声明的地方一直到所在文件的末尾都是可见的。 链接 。变量的链接确定了程序的不同部分可以共享此变量的范围。 外部链接 ：变量可以被程序中的几个（或全部）文件共享。 内部链接 ：变量只能属于单独的一个文件，但是此文件中的函数可以共享这个变量。 无连接 ：变量属于单独一个函数，而且根本不能被共享。 变量的默认存储期限，作用域和链接都依赖于变量声明的位置： 在块内声明的变量（如图） 在程序外层（任意块外部）声明的变量（如图） 2. auto 存储类型 auto 存储类型只对属于块的变量有效。auto 变量具有自动存储期限，块作用域，无链接。auto 存储类型几乎从来不用明确的指明，因为在块内部声明的变量，它是默认的。 3. static 存储类型 static 作用于块外部和块内部的变量效果不同。如图： 下面的例子中，函数 f1 和 f2 可以访问变量 i，但是其他文件中的函数不可以： static 的此用法可以用来实现一种称为 信息隐藏 的技术。 块内声明的 static 变量在程序执行期间驻留在同意存储单元内。和每次程序离开所在块就会丢失值的自动变量不同， static 变量会无限期地保留值。static 变量具有以下性质： 块内声明的 static 变量只在程序执行前进行一次初始化，而 auto 变量则会在每次出现时进行初始化。（当然，假设它有初始化式） 含有 static 变量的函数全部调用共享这个 static 变量。 虽然函数不应该返回指向 auto 变量的指针，但是函数返回指向 static 变量的指针是没有错误的。 声明函数中的一个变量为 static，这样做允许函数在“隐藏区域”的调用之间保留信息。隐藏区域是程序其他部分无法访问到的地方。思考下列函数： 每次调用 digit_to_hex_char 函数时，都会把字符串字面量\"0123456789ABCDEF\"赋值给数组 hex_chars[16] 来对其初始化。现在，把数组设为 static： 由于 static 变量只进行一次初始化，这样就改进了 digit_to_hex_char 函数的速度。 4. extern 存储类型 extern 存储类型可以使几个源文件可以共享同一个变量。前面我们也讲过它，这里不再重复。 下列声明给编译器提供的信息是 i 是 int 型变量： 但是这样不会导致编译器为变量 i 分配存储单元。用 C 的术语来说，上述声明不是变量 i 的定义，他只是提示编译器需要访问定义在别处的变量。（可能稍后在同一文件中，更常见的是在另一个文件中。）变量在程序中可以有多次声明，但是定义只能有一次。 对变量进行初始化的 extern 声明是变量的定义。例如： 等效于： extern 声明中的变量始终具有静态存储期限。变量的作用域依赖于声明的位置。如图： 确定 extern 型变量的链接有一定难度。如果变量在文件中较早的位置（任何函数外部）声明为 static ，那么它具有内部链接；否则（通常情况下），变量具有外部链接。 如何理解上面这段话呢，请看下面的程序： 编译运行这个程序，没有编译错误和链接错误。程序执行结束，n 会被增加 1 。 如果我们在另一个文件的函数中想访问 n： file1.c file2.c 编译运行这个程序，也没有编译错误和链接错误。程序执行结束，n 会被增加 1 。 这时，n 具有外部链接 我们对程序稍作修改： 编译运行这个程序，出现链接错误。我们需要将 n 的定义放在调用前： 编译运行这个程序，没有编译错误和链接错误。程序执行结束，n 会被增加 1 。 这时，如果我们想在另一个文件中访问 n，可以实现吗？ file1.c file2.c 编译运行这个程序，出现链接错误。 此时，n 具有内部链接 。 5. register 存储类型 声明变量具有 register 类型就要求编译器把变量存储在 寄存器 中，而不是像其他变量一样保留在内存中。（寄存器是驻留在 计算机 CPU 中的存储单元。存储在寄存器中的数据会比存储在普通内存中的数据访问和更新速度更快。指明变量的存储类型是 register 是一种 请求 ，而不是命令。编译器可以选择把 register 类型的变量存储在内存中。 register 存储类型只对声明在块内的变量有效。register 变量具有和 auto 变量一样的存储期限，作用域和链接。但是，由于 寄存器没有地址 ，所以对 register 变量取地址 &#x26; 是非法的。即使编译器选择将其存储在内存中，这一限制仍然适用。 register 存储类型最好用于需要 频繁进行访问或更新 的变量。例如： 现在 register 不像以前那么流行 了。当今的编译器比早期的 C 语言编译器复杂多了，许多编译器可以 自动确定 哪些变量保存在寄存器中可以获得最大好处。 6. 函数的存储类型 函数声明或定义存储类型选项只有： extern 和 static 在函数声明开始处的单词 extern 说明函数具有外部链接，也就是允许其他文件调用此函数（默认情况下）； static 说明是内部链接，也就是说只有在定义函数的文件内调用此函数。思考下面的函数声明： 把 g 声明为 static 不能完全阻止在别的文件中对它的调用，通过 函数指针 进行间接调用仍然是可能的。 使用 static 的好处： 更容易维护 。把函数声明为 static 存储类型保证在函数定义出现的文件之外函数 f 都是不可见的。因此，以后修改程序的人可以知道对函数 f 的变化不会影响其他文件中的函数。（另一个文件中如果传入了指向函数 f 的指针，它可能会收到函数 f 变化的影响。幸运的是，这种问题很容易通过检查定义函数 f 的文件来发现，因为传递 f 的函数一定也定义在此文件中。） 减少了“名字空间污染” 。用于声明 static 的函数具有内部链接，所以可以在其他文件中重新使用这些函数名。虽然我们不太可能会为一些其他目的故意使用相同的函数名，但是在大规模程序中这种现象是难以避免的。 三 类型限定符 C 语言中一共有两种类型限定符： const 和 volatile (C99 中还有第三种： restrict ，它只用于指针。)因为 volatile 只用于底层编程中，我们会在后面的章节中进行讨论。 const 用来声明一些类似于变量的对象。但这些变量是“只读”的。程序可以访问 const 型对象的值，但是无法改变它的值。例如： 把对象声明为 const 有以下几个好处： const 是文档格式 。声明对象是 const 类型可以提示阅读程序的人，该对象的值不会改变。 编译器可以检查程序没有特意地试图改变该对象的值 。 const 与 #define 之间的差异： #define 指令为数值，字符或字符串常量创建名字； const 可用于产生任何类型的只读对象，包括数组，指针，结构或联合。 const 对象遵循与变量相同的作用域规则；#define 创建的常量不受这些规则的限制。特别是，不能用 #define 创建具有块作用域的常量。 和宏的值不同，const 对象的值可以在调试器中看到。 不同于宏， const 对象的值不可用于常量表达式 。比如： 在 C99 中，如果 a 具有自动存储期限，那么这个例子是合法的——它会被视为变长数组；但是如果 a 具有静态存储期限，那么这个例子是不合法的。 对 const 对象应用取地址运算符 &#x26; 是合法的，因为它有地址。宏没有地址。 四 声明符 声明符包含标识符，符号（ * , [] , () ） 1. 解释复杂声明 下面这个声明符是什么意思呢？ 理解声明符的规则： 从内向外读声明符 。定位声明的标识符，并且从此处开始解释声明。 在做选择时，使用使 [] 和 () 优先于 * 。 先看一些简单的声明： ap 是标识符，[] 优先级高于 *，所以 ap 是指针数组。 fp 是标识符，() 优先于 *，所以 fp 是返回指针的函数。 由于 *pf 包含在圆括号内，所以 pf 一定是一个函数指针，此函数返回值类型为 void ，参数为 int 类型。 再来看前面的这个声明： 找到 x， x[10] 表示数组， *x[10] 表示指针数组， (*[x10]) 表示这是一个元素都是指向函数的指针数组，此函数返回值类型是 int* ，没有参数。 五 初始化式 初始化式我们并不陌生，现在我们来看一些控制初始化式的额外规则： 具有静态存储期限的变量的初始化式必须是常量： 如果变量具有自动存储期限，那么它的初始化式不需要式常量： 包含在花括号中的数组，结构和联合的初始化式必须只包含常量表达式，允许有变量或函数调用： C99 中，仅当变量具有静态存储期限时，这一限制才生效。 自动类型的结构或联合的初始化式可以是另一个结构或联合： 初始化式不一定非要是变量。比如： 1. 未初始化的变量 变量的默认初始化依赖于变量的 存储类型 ： 具有 自动存储期限 的变量没有默认初始值。不能预测自动变量的初始值，每次变量变为有效时只可能不同。 具有 静态存储期限 的变量默认情况下为 0 。整型变量初始化为 0，符点变量初始化为 0.0，指针初始化为 NULL（空指针）。 出于书写风格和可读性的考虑， 最好为静态类型的变量提供初始化式 。 六 内联函数（C99） 略 参考资料：《C语言程序设计：现代方法》 Footnotes 模块是误解之源；信息隐藏预示沟通的必要。 Epigrams on Programming 编程警句 ↩"},{"title":"程序设计","slug":"c-modern-approach/20-program-design","permalink":"/kb/posts/c-modern-approach/20-program-design","category":"c-modern-approach","description":"Optimization hinders evolution. [^1]","date":"2026-06-16T00:00:00.000Z","content":"程序设计 Optimization hinders evolution. 1 目录 [TOC] 程序设计 零 前言 实际应用中的程序显然比本系列教学的例子要大，但是你可能不会意识到会大多少。如今，大多数功能完整的程序至少有十万行代码，百万行级的程序已经很常见。 虽然 C 语言不是专门用来编写大型程序的，但许多大型程序的确是用 C 编写的。 编写大型程序（通常称为“大规模程序设计”）与编写小程序有很大的不同——就如同写一篇学期论文与写一本长篇小说之间的差别一样。大型程序需要更加注意编写风格，因为会有很多人一起工作。需要有正规的文档，同时还需要对维护进行规划，因为程序可能会多次修改。 尤其是，相对于小型程序，编写大型程序需要更仔细的设计和更详细的计划。 一 模块 设计 C 程序（或其他任何语言的程序）时，最好将它看作是一些独立的 模块 。模块是一组服务的集合，其中一些服务可以被程序的其他部分（称为 客户 ）使用。每个模块都有一个 接口 来描述所提供的服务。模块的细节（包括这些服务自身的源代码）都包含在模块的 实现 中。 在 C 语言环境下，这些“服务”就是函数。模块的接口就是 头文件 ，头文件包含那些可以被程序其他文件调用的函数的原型。模块的实现就是包含该模块中函数的定义的 源文件 。 比如，前面我们写的 程序：文本格式化 中 line.h 和 word.h 就是接口，line.c 和 word.c 就是实现，包含 main 函数的 justify.c 为客户。 将程序分割成模块有一系列好处。 抽象 。 我们知道模块会做什么，但是不需要知道这些功能的实现细节。我们不必为了修改部分程序而了解整个程序是如何工作的。 可复用性 。任何一个提供服务的模块都有可能在其他程序中复用。 可维护性 。将程序模块化后，程序中的错误通常只会影响一个模块实现，因而更容易找到错误并修正错误。在修正了错误之后，重建程序只需要重新编译该模块实现（然后重新链接整个程序）。 比如我们以 inventory 程序为例。最初将零件记录存储在一个数组中。假设在程序使用一段时间后，客户不同意对零件存储数量设置固定的上限。为了满足客户需求，我们可能会改用链表。为了这个修改，需要仔细检查整个程序，找出所有依赖于零件存储方式的地方。如果一开始就采用了不同的方式来设计程序，我们可能只需要重写这一个模块的实现，而不需要重写整个程序。 一旦我们确定了要进行模块设计，设计程序的过程就变成了确定 需要哪些模块，每个模块应该提供哪些服务，各个模块之间的相互关系是什么。 二 信息隐藏 设计良好的模块经常会对它的客户隐藏一些信息。例如，我们的栈模块的客户就不需要知道栈是用数组，链表还是其他形式存储的。这种故意对客户隐藏信息的方法称为信息隐藏。信息隐藏有两大优点： 安全性 。如果客户不知道栈是如何存储的，就不可能通过栈的内部机制擅自修改栈的数据。 灵活性 。无论对模块的内部机制进行多大的改动，都不会很复杂。 C 语言中，强制信息隐藏的主要工具是 static 存储类型。将具有文件作用域的变量声明称 static 可以使其具有内部链接，从而避免它被其他文件（包含模块的客户）访问；将函数声明成 static 也可以使其具有内部链接，这样函数只能被同一文件中的其他函数直接调用。 1. 栈模块 为了清楚地看到信息隐藏所带来的好处，下面我们来看看栈模块的两种实现。一种使用数组，一种使用链表。我们假设模块的头文件如下所示： stack.h 数组实现： stack1.c 组成栈的变量（contents 和 top）都被声明为 static 了，因为没有理由让程序的其他部分直接访问它们。terminate 函数也声明为 static 。这个函数不属于模块的接口；相反，它只能在模块的实现内使用。 我们可以用宏来指明那些函数和变量是公有的（程序的任何地方可以访问）或私有的（一个文件内访问）： 下面是使用 PUBLIC 和 PRIVATE 栈实现的样子： 链表实现： stack2.c 三 抽象数据类型 作为抽象对象的模块（比如上面的栈模块）有一个严重的缺点：无法拥有该对象的多个实例（本例中指多个栈）。为了达到这个目的，我们需要进一步创建一个新的类型。 我们不知道 s1 和 s2 究竟是什么（结构？指针？），但这并不重要。对于栈模块的客户，s1 和 s2 是抽象，它只响应特定的操作。 修改头文件： stack2.h 函数 make_empty, push 和 pop 参数的栈变量需要为指针，因为这些函数会改变栈的内容。is_empty 和 is_full 函数的参数并不需要为指针，但我们依然使用指针，因为传递 Stack 值会导致整个数据结构被复制。 1. 封装 遗憾的是，上面的 Stack 不是抽象数据类型，因为 stack2.h 暴露了 Stack 类型的具体实现方式，因此无法阻止客户将 Stack 变量作为结构直接使用： 所以，我们真正需要的是一种组织客户知道 Stack 类型具体实现的方式。C 语言对于封装类型的支持有限。新的基于 C 的语言（Java，C++ 和 C#）对于封装的支持更好一些。 2. 不完整类型 C 语言提供的唯一的封装工具为 不完整类型 （incomplete type）。C 标准对不完整类型的描述为：描述了对象但缺少定义对象大小所需的信息。例如，声明： 告诉编译器 t 是一个结构标记，但没有描述结构的成员。所以编译器没有足够的信息去确定该结构的大小。这样做的意图是：不完整类型将会在程序的其他地方将信息补充完整。 不完整类型的使用是受限的。因为编译器不知道不完整类型的大小，所以它不能用于变量达到声明： 但是完全可以定义一个指针类型引用不完整的类型： 可以声明类型 T 的变量，将其作为函数的参数传递，并可以执行合法的指针运算（指针的大小不依赖于它所指向的对象，这就解释了为什么 C 语言允许这种行为。）。但是我们不能使用 -> 运算符。 四 栈抽象数据类型 为了说明怎么利用不完整数据类型进行封装，我们需要开发一个基于前面描述的栈模块的栈抽象数据类型（Abstract Data Type，ADT）。这一过程中，我们将用 3 种不同的方法实现栈。 1. 为栈抽象数据类型定义接口 Stack 类型作为指针指向 stack_type 结构。这个结构是一个不完整类型，在实现栈的文件中信息将变得完整。 stackADT.h 包含头文件 stackADT.h 的客户就可以声明 Stack 类型的变量，这些变量都可以指向 stack_type 结构。之后客户就可以调用在 stackADT.h 中的函数来对栈进行操作。但是客户不能访问 stack_type 结构的成员，因为该结构定义在另一个文件中。 下面的客户文件可以用于测试栈抽象数据类型。 stackclient.c 输出： 2. 使用定长数组实现栈抽象数据类型 stackADT.c 3. 改变栈抽象数据类型中的数据的类型 栈中的项都是整数，太具有局限性了。为了使栈抽象数据类更易于针对不同的数据项类型进行修改，我们在 stackADT.h 中增加了一行类型定义。现在用类型名 Item 表示存储到栈中的数据的类型。 stackADT2.h 修改 stackADT.c : 我们只需将 int 出现的地方换为 Item 即可： 4. 用动态数组实现栈抽象数据类型 修改 stack_type 结构： 使 contents 成员为指向数据项所在数组的指针，而不是数组本身；增加 size 成员来存储栈的最大容量（contents 数组长度）。使用这个成员检测“栈满”情况。使用柔性数组可以减少 create 函数中的一次 malloc。（什么是柔性数组？ https://mp.weixin.qq.com/s/FfNI5ooT75VyIdM9dmiq-A） stackADT3.h stackADT3.c 修改的地方不多： 事实上，你可以不使用柔性数组：create 函数先为结构变量整体 malloc，然后再为表示栈的数组 malloc 。同样，释放时也需要 2 次分步释放。 客户文件在调用 create 时需要给出栈的大小： 5. 使用链表实现栈抽象数据类型 链表中的结点用如下结构表示： 为了使栈的接口不变，我们需要再定义一个包含指向链表首节点的结构： stackADT4.h stackADT4.c 五 抽象数据类型的设计问题 前面描述了栈的抽象数据类型，并介绍了几种实现方法。遗憾的是，这里的抽象数据类型存储一些问题，使其达不到工业级强度。 1. 命名惯例 目前的栈抽象数据类型函数都采用简短，便于记忆的名字：create，destroy 等。如果一个程序中有多个抽象数据类型，两个模块中很可能具有同名函数，这样就出现了名字冲突。所以，我们可能需要在函数名中加入抽象数据类型本身的名字。 下面是修改后的部分头文件： 2. 错误处理 栈抽象数据类型通过显示出错误消息或终止程序的方式来处理错误。这是一个不错的方式，但是，我们希望为程序提供一种从这些错误中恢复的途径，而不是简单的终止程序。 一种方式是让 push 和 pop 函数返回一个 bool 类型的值说明函数调用是否成功。push 返回类型为 void，所以很容易改为成功时返回 true，失败时返回 false；但是修改 pop 就没那么简单了，因为目前 pop 是返回 Item 类型的值。如果让 pop 返回指向弹出的值的指针而不是数值，我们可以让 pop 返回 NULL 表示栈为空 。 修改后的函数定义如下： 最后，C 库包含 assert 宏，可以在指定条件不满足时终止程序。我们可以用改宏的调用取代目前使用的 if 语句和 terminate 函数。 3. 通用抽象数据类型 现在的抽象数据类型栈还存在一个严重问题：程序不能创建两个数据类型不同的栈。 为了允许多个栈具有不同数据类型，我们可以复制栈抽象数据类型的头文件和源文件，并改变 Item 的类型定义，然后使 Stack 类型以及相关函数具有不同的名字。 我们希望有一个“通用”的栈类型。C 语言有很多不同的途径做到这一点，但是没有一个是令人满意的。最常见的一种方法是使用 void* 作为数据项类型，这样就可以使用各种类型的指针了。 只需要修改接口中的 push 和 pop 函数： 那么程序应该如何改写呢？这个问题留给大家吧。 使用 void* 作为 数据项类型有两个缺点： 这种方法不适用于无法用指针形式表示的数据 不能进行函数参数的错误检查 4. 新语言中的抽象数据类型 上面的问题在新的基于 C 的语言（C++，Java，C#）中处理的更好。 通过在类中定义函数可以避免名字冲突问题 这些语言都提供了一种称为 异常处理 的特性 专门提供了定义通用数据类型的特性。例如，在 C++ 中我们可以定义一个 模板 ，而不是指定数据项的类型。 参考资料：《C语言程序设计：现代方法》 Footnotes 优化阻碍进化。 Epigrams on Programming 编程警句 ↩"},{"title":"底层程序设计","slug":"c-modern-approach/21-low-level-programming","permalink":"/kb/posts/c-modern-approach/21-low-level-programming","category":"c-modern-approach","description":"A good system can't have a weak command language. [^1]","date":"2026-06-16T00:00:00.000Z","content":"底层程序设计 A good system can't have a weak command language. 1 目录 [TOC] 底层程序设计 零 前言 有些程序需要进行位级别的操作。位操作和其他一些底层运算在编写系统程序（包括编译器和操作系统），加密程序，图形程序以及一些其他需要高执行速度或高效利用空间的程序非常有用。 一 位运算符 C 语言提供了 6 个位运算符。 关于位运算符参考文章： https://mp.weixin.qq.com/s/rWFUortJu0JAw1kIwSegRQ 1. 移位运算符 &#x3C;&#x3C; 左移位 >> 右移位 操作数可以为任意整数类型（包括 char型）。对两个操作数都会进行整数提升，返回值的类型是左操作数提升后的类型。 i &#x3C;&#x3C; j 将 i 中的位左移 j 位后的结果。每次从 i 最左端溢出一位，在 i 的最右端补 0 i >> j 将 i 中的位右移 j 位后的结果。每次从 i 的最右端溢出一位。如果 i 是无符号数或非负值，在 i 左端补 0；如果 i 是 负值，其结果是由实现定义的：一些实现会在左端补 0，其他实现会在保留符号位而补 1 。 **可移植性技巧：**仅对无符号数进行位运算。 例： 如上面所示，两个运算符都不会改变他它的操作数。如果想要改变： 位移运算符的优先级比算数运算符的优先级 低 ，所以： 等价于： 而不是： 2. 其他位运算符 ~ 按位求反 &#x26; 按位与 | 按位或 ^ 按位异或 ~ 是一元运算符，对操作数进行整数提升；其他是二元运算符，对操作数进行常用的算术转换。 它们对操作数的每一位进行布尔运算。 注意：不要混淆 &#x26; 与 &#x26;&#x26; ， | 与 || ，它们绝不相同。 例： 其中对 ~i 是基于 unsigned short 类型的值占有 16 位的假设。 运算符可以帮助我们使底层程序的可移植性更好。比如我们需要一个整数，它的所有位都为 1，写成 ~0 ;如果我们需要一个整数，除了最后 5 位其他位数都为 1，写成 ~0x1f 优先级： 复合赋值运算符： 3. 用位运算符访问位 位的设置。 假设我们需要设置 i 的第 4 位（我们假定最高有效位为第15位，最低有效位为 0 位。）。将 i 的值与 0x0010（一个在第 4 位上为 1 的“掩码”）进行 或 运算： 如果需要设置的位存储在 j 中，可以用移位运算符构造掩码： 如果 j 的值为 3， 1 &#x3C;&#x3C; j 是 0x0008 位的清除 。要清除 i 的第 4 位，可以使用第 4 位为 0 ，其他位为 1 的掩码： 按照相同的思路，得出惯用法： 位的测试 。下面的 if 语句测试 i 的第4位是否被设置： 测试第 j 位是否被设置，使用惯用法： 为了使位的操作更容易，经常会给他们起名字，给第 0，1，2 位定义名字： 设置，清除或测试 BLUE 可以如下进行： 同时对几个位操作也一样简单： if 语句测试 BLUE 位或 GREEN 位是否被设置了。 4. 用位运算符访问位域 处理一组连续的位（ 位域 ）比处理单个位要复杂一点。下面是两种最常见的位域操作的例子。 修改位域 。修改位域需要先使用 按位与 （清除位域），再使用 按位或 （存入位域）。 将二进制的 101 存入 i 的 4~6 位： 注意：使用 i |= 0x0050 并不总是可行，这只会设置第 6 位和第 4 位，但是不会改变第 5 位。 假设 j 包含了需要存储到 i 的第 4 位到第 6 位的值。我们需要在执行按位或操作之前将 j 移位至相应的位置： 因为 &#x3C;&#x3C; 优先级高于 | ，我们可以省略括号（加上也没问题）。 获取位域 。当位域处在数的最右端（最低有效位）时，获取它十分简单。例如，获取变量 i 的第 0~2 位： 如果位域不在 i 的右端，那么需要先将位域移位至右端，在使用 &#x26; 提取位域。例如，获取 i 的第 4~6 位： 程序：XOR 加密 对数据加密的一种最简单的方法就是，将每个字符与一个密钥进行异或（XOR）运算。假设密钥时一个 &#x26; 字符。如果将它与字符 z 异或，我们会得到 \\ 字符（假定字符集为 ACSII 字符集）。具体计算如下： 要将消息解密，只需要采用相同的算法。例如，如果将 &#x26; 与 \\ 异或就可以得到 &#x26;： 下面的程序 xor.c 通过每个字符于 &#x26; 字符进行异或来加密消息。原始消息可以由用户输入也可以输入重定向从文件读入。加密后的消息可以在屏幕上显示也可以通过输出重定向存入到文件中。例如 msg 文件包含以下内容： 为了对文件 msg 加密并将加密后的消息存入文件 newmsg 中，输入以下命令： 文件 newmsg 将包含下面的内容： 要恢复原始消息，需要命令： 将原始消息显示在屏幕上。 正如例子中看到的那样，程序不会改变一些字符，包括数字。将这些字符于 &#x26; 异或会产生不可见的控制字符，这在一些操作系统中会引发问题。在这里，为了安全起见，我们使用 isprint 函数来确保原始字符和新字符都是可打印字符（即不是控制字符）。如果不满足，让程序写原始字符，而不是新字符。 xor.c 二 结构中的位域 声明其成员表示位域的结构。 例如，我们来看看 MS-DOS 操作系统（通常简称 DOS）是如何存储文件的创建和最后修改日期的。由于日期，月和年都是很小的数，将它们按整数存储会浪费空间。DOS 只为日期分配了 16 位，5 位用于日，4 位用于 月，7 位用于年。 利用位域，我们可以定义相同形式的 C 结构： 每个成员后面指定了它所含用的长度。因为所有成员类型一样，我们可以简化声明： 位域的类型必须是 int , unsigned int , signed int 。使用 int 会引发二义性，因为一些编译器将位域的最高位作为符号位，而其他一些编译器不会。 可移植性技巧 ：将所有的位域声明为： unsigned int 或 signed int C99 中，位域也可以具有类型 _Bool 。 我们可以将位域像结构的其他成员一样使用： 注意：year 成员相对于 1980 年（根据微软的描述，这是 DOS 出现的时间）存储的。在这些赋值语句之后，变量 fd 的形式如下所示： 使用位运算可以达到同样的效果，使用位运算甚至可以更快。但是，让程序更易度通常比节省几微妙更重要一些。 通常意义上讲， 位域没有地址 ，C 语言不允许讲 &#x26; 运算符用于位域。由于这条规则的限制，像 scanf 这样的函数无法直接向位域中存储数据： 1. 位域是如何存储的 C 标准在如何存储位域方面给编译器保留了相当的自由度。 “存储单元”的大小是由实现定义的，通常为 8 位，16 位或 32 位。当编译器处理结构的声明时，会将位域逐个放入存储单元，位域之间没有空隙，直到剩下的空间不够存放下一个位域了。这时，一些编译器会跳到下一个存储单元的开始，而另一些则会将位域拆开夸存储单元存放。位域存放的顺序（从左至右，还是从右至左）也是由实现定义的。 前面的 file_data 例子假设存储单元是 16 位长的。我们也假设位域是从右向左存储的。（第一个位域占据低序号位） C 语言允许省略位域的名字。未命名的位域经常作为字段之间的“填充”，以保证其他位于存储在适当的位置。考虑与 DOS 文件关联的时间，存储方式如下： 你可能会奇怪将秒 —— 0 ~ 59 之间的数存放在一个 5 位字段中呢。实际上，DOS 将描述除以 2，因此 seconds 成员实际存储的是 0 ~ 29 的数。如果我们不关心 seconds 字段，可以不给它命名： 其他位域仍会正常对齐。 另一个用来控制位于存储的技巧是指明未命名字段长度为 0： 长度为 0 的位域是给编译器的一个信号，告诉编译器将下一个位域在一个存储单元的起始位置对齐。假设存储单元是 8 位长的，编译器会给成员 a 分配 4 位，接着跳过余下的 4 位到下一个存储单元，然后给成员 b 分配 8 位。如果存储单元是 16 位，则会在 a 分配 4 位后跳过余下的 12 位分配 b。 三 其他底层技术 1. 定义依赖机器的类型 依据定义，char 类型占据一个字节，所以我们有时当字符是字节，并用它们存储一些并不一定是字符形式的数据。但这时候最好定义一个 BYTE 类型： x86 体系结构大量使用了 16 位的字，我们可以定义： 2. 用联合提供数据多个视角 在 C 语言中，联合经常被用于 从两个或更多的角度看代内存块 。 前面我们知道 file_date 结构正好可以放入两个字节，我们可以将任何两个字节的数据当作是一个 file_date 结构。下面定义一个联合可以使我们很方便的将一个短整数与文件日期相互转换： 通过这个联合，我们可以以两个字节的形式获取磁盘文件中的日期，然后提取出其中的 month, day, year 字段的值。相反的，我们可以以 file_date 结构构造一个日期，然后作为两个字节写入磁盘中。例如： x86 处理器包含 16 位寄存器 ——AX，BX，CX 和 DX 。每一个寄存器都可以看作是两个 8 位的寄存器。例如，AX 可以被划分为 AH 和 AL 两个寄存器。 当针对于 x86 的计算机编写底层程序时，可能会用到寄存器中的值的变量。我们需要对 16 位寄存器和 8 位寄存器进行访问，同时要考虑到它们之间的关系（改变 AX 会改变 AH 和 AL；反之同理）。所以我们可以构造一个联合包含两个结构（分别存储 16 位和 8 位的寄存器）： 下面时是一个使用 regs 联合的例子： 输出： 注意，尽管 AL 寄存器是 AX 寄存器的“低位”部分而 AH 是“高位”部分，但在 byte 结构中 al 在 ah 之前。原因是，当数据项多于一个字节时，在内存中有两种存储方式： 大端 (big-endian) 和 小端 (small-endian) 。小端代表：低位内存（al 是最低位）存储数的低位（34 是低位），大端则相反（可以记小端为：小小小）。C 对存储的的顺序没有要求，因为这取决于程序执行时所使用的 CPU。x86 处理器假设数据按小段方式存储。 在底层对内存进行操作的程序必须注意字节的存储顺序 。处理含有非字符数据的文件时也要当心字节的存储顺序。 3. 将指针作为地址使用 指针实际上就是一种内存地址。地址所包含的位的个数与整数（或长整数）一致。构造一个指针来表示某个特定的地址是十分方便的：只需要将整数强转为指针就行。比如，将地址 1000 存入一个指针变量： 程序：查看内存单元 这个程序允许用户查看计算机内存段，这主要得益于 C 允许把整数用作指针。大多数 CPU 执行程序时都是处于“保护模式”，这就意味着程序只能访问那些分配给它的内存。这种方式还可以阻止对其他应用程序和操作系统本身所占用的内存的访问。因此我们只能看到程序本身分配到的内存，如果要对其他内存地址进行访问将导致程序崩溃。 程序 veiw_memory.c 先显示了该程序主函数和主函数中第一个变量的地址，这样可以给用户一个线索去了解那个内存可以被探测。程序接下来提示用户输入地址（16 进制格式）和需要查看的字节数，然后从指定地址开始显示指定字节内存块的内容。 字节按 10 个一组的方式显示（最后一组可能达不到 10 个）。每组字节的首地址显示在一行的开头，然后是该组的字节（16 进制格式），再后面为该组字节的字符显示。只有打印字符（使用 isprint 函数判断）会被显示，其余的被显示为 . 。 假设 int 类型大小为 32 位，地址也是 32 位长。 格式如下： . （前 4 个字节是我们输入的表示地址的整数，注意它的每个字节存储顺序） view_memory.c 4. volatile 类型限定符 在一些计算机中，一部分内存空间是“易变”的，保存在这"},{"title":"输入/输出","slug":"c-modern-approach/22-input-output","permalink":"/kb/posts/c-modern-approach/22-input-output","category":"c-modern-approach","description":"C语言输入输出函数：scanf、printf、文件读写操作","date":"2026-06-16T00:00:00.000Z","content":"输入/输出 To understand a program you must become both the machine and the program. 1 目录 [TOC] 输入\\输出 零 前言 C 语言的输入\\输出库是标准库中最大且最重要的部分。 我们除过继续深入讨论 printf 函数和 scanf 函数以及相关函数之外，还会讨论： 每次读写一个字符的函数： getc 函数和 putc 函数以及相关函数。 每次读写一行字符的函数： gets 函数和 puts 函数以及相关函数。 读写数据块的 fread 函数和 fwrite 函数 本章涵盖了 &#x3C;stdio.h> 中的大部分函数，但忽略了 8 个函数。于 &#x3C;errno.h> 头相关的函数和依赖 va_list 类型的函数我们也许会在后面介绍。 在 C89 中，所有标准输入输出函数都属于 &#x3C;stdio.h> 头。但是 C99 有些输入输出函数在头 &#x3C;wchar.h> 中声明。 &#x3C;stdio.h> 中用于读或写数据的函数称为 字节输入\\输出函数 ； &#x3C;wchar.h> 中的类似函数称为 宽字符输入\\输出函数 。 相关的文章之前我写过一些，推荐和本文一起阅读，重复的内容有些会不再提及。参考文章： https://mp.weixin.qq.com/s/H1Yp5miEf8NP4HdP8OECqg 一 流 流（stream）表示任意输入的源或任意输出的目的地。 许多小型程序都是通过一个流（通常与键盘相关）获得全部的输入，并且通过另一个流（通常和屏幕相关）写出全部的输出。 1. 文件指针 C程序中对流的访问是通过文件指针实现的 。此指针类型为 FILE* （FILE*类型在&#x3C;stdio.h>中声明），声明流： 2. 标准流和重定向 &#x3C;stdio.h>提供了3个标准流。这三个标准流可以直接使用——我们不需要对其进行声明，也不用打开或者关闭它们。 文件指针 流 默认的含义 stdin 标准输入 键盘 stdout 标准输出 屏幕 stderr 标准错误 屏幕 printf,scanf,putchar,getchar,puts,gets 都是通过stdin进行输入，stdout进行输出的。默认情况下，stdin表示键盘，而stdout，stderr表示屏幕。然而，许多操作系统允许通过一种称为**重定向（redirection）**的机制来改变这些默认的含义。 输入重定向（input redirection） 强制程序从文件而不是键盘获得输入，本质是使stdin流表示文件而非键盘。 重定向的绝妙之处在于，demo程序不会意识到正在从文件in.dat中读取数据，他会认为从stdin获得的任何数据都是从键盘上录入的。 **方法：**demo &#x3C; in.dat 输出重定向 (output redirection) 方法 ：demo > out.dat 注意： 重定向输出入与输出可以结合使用，&#x3C; 和 > 不需要与文件名相临，重定向文件顺序也是无关紧要的。 所以下面两种表示方法的效果是一样的： 3. 文本文件与二进制文件 &#x3C;stdio.h>支持两种文件类型: 文本文件 和 二进制文件 **文本文件（text file）**中， 字节表示字符 。（C程序的源代码就储存在文本文件中） **二进制文件(binary file)**中， 字节不一定表示字符 ；字节组还可以表示其它类型的数据，比如整数与浮点数（可执行C程序存贮在二进制文本中） 文本文件中二进制文件没有的特性： 1.文本文件分为若干行 文本文件的每一行通常以一两个特殊字符结尾。特殊字符与操作系统有关： Windows中，行末标记是回车符（'\\x0d'）+ 换行符('\\x0a') Unix 与新的MacOS中，行末是一个单独的换行符 2.文本文件可以包含一个特殊的“文件末尾”标记 一些操作系统允许在文本文件的末尾使用一个特殊字节作为标记。在Windows中，标记为 '\\x1a' (Ctrl + Z)。 Ctrl + Z 不是必须的，但如果存在， 它就标志着文件的结尾，其后所有的字节都被忽略。 大多数其他操作系统（包括UNIX）没有专门的文件末尾符 二进制文件不分行，也没有行末标记和文件末尾标记 ，所有字节都是平等对待的 例1：向文件写入数据时，我们需要考虑按文本格式存储还是按二进制格式进行存储。为了搞清其中的差别，考虑在文件中存储数32767的情况。 从上例可以看出： 二进制形式存储数据可以节省相当大的空间 总结： 在屏幕上显示文件内容的程序可能要把文件视为文本文件。但是，文件复制程序就不能认为要复制的文件为文本文件（考虑到文本文件可能含有文件末尾字符，这样就不能复制完全）。 在无法确定文件是文本文件还是二进制文件时，安全的做法是把文件假定为二进制文件。 二 文件操作 简单性是输入和输出重定向的魅力之一：不需要打开文件，关闭文件或执行任何其他的显示文件操作。可惜的是，重定向在许多应用中受到限制。当程序依赖重定向时，它无法控制自己的文件，甚至无法知道这些文件的名字。如果程序需要在同一时间读入两个文件或者写出两个文件时，重定向都无法做到。 当重定向无法满足需要时，我们需要使用 &#x3C;stdio.h> 提供的文件操作。 1. 打开文件 如果要把文件用作流，打开时需要使用 fopen 函数。 FILE *fopen( const char *filename, const char *mode ) ;(C99 前) FILE *fopen( const char *restrict filename, const char *restrict mode ) ;(C99 起) filename - 关联到文件系统的文件名 mode - 确定访问模式的空终止字符串 若成功，则返回指向新文件流的指针。流为完全缓冲，除非 filename 表示一个交互设备。错误时，返回空指针 第一个参数 filename 可能包含文件位置信息，如驱动器符或路径。 第二个参数：比如字符串 \"r\" 表示 只读 。 注意：C99 中，函数原型有 restrict 关键字，表明 filename 和 mode 所指向的字符串内存单元不共享。 C89 中不包含 restrict，但是也要有这样的要求。restrict 对 fopen 操作没有影响，所以通常可以忽略。 Windows 中：fopen 调用的文件名中有字符 \\ 时，一定要小心。因为 C 会把它看为是转义序列开始的标志： 这个调用会失败。编译器会把 \\p 和 \\t 看为转义字符。（\\p 并不是有效的转义字符。） 正确的用法： 另一种方法更简单——只需要用 / 替代 \\ 即可： Windows 会把 / 接受为目录分隔符。 把 fopen 的返回值存储在变量中： 当程序稍后调用输入函数从文件 1.dat 中读取数据时，将会把 fp 作为一个实际参数。 如果返回的是空指针，可能： 文件不存在 文件路径错误 我们没有打开文件的权限 注意：永远不要假设可以打开文件，每次都要测试 fopen 函数的返回值确保不是空指针。 2. 模式 文本文件： 二进制文件 ：需要在模式字符串中包含字母 b \"rb\" \"wb\" \"ab\" , \"rb+\" (或 \"r+b\" ，后同)， \"wb+\" ， \"ab+\" 3. 关闭文件 int fclose( FILE *stream ) **头文件：**stdio.h 参数： stream - 需要关闭的文件流 返回值： 成功时为 0 ，否则为 EOF 。 fclose 文件的参数必须是文件指针，此指针来自于 fopen 函数或 freopen 函数的调用。 下面给出了一个程序的框架。此程序打开文件 example.dat 进行读操作，并检查打开是否成功，让后在程序终止前把文件关闭： 可以将 fp 的声明与函数调用结合： 还可以将函数调用与 NULL 判定结合： 4. 为打开的流附加文件 FILE *freopen( const char *filename, const char *mode, FILE* stream ); (C99 前) FILE *freopen( const char *restrict filename, const char *restrict mode, FILE *restrict stream ); (C99起) 首先，试图关闭与 stream 关联的文件，忽略任何错误。然后，若 filename 非空，则试图用 mode 打开 filename 所指定的文件，如同用 fopen ，然后将该文件与 stream 所指向的文件流关联。若 filename 为空指针，则函数试图重打开已与 stream 关联的文件（此情况下是否允许模式改变是实现定义的）。 参数： filename - 要关联到文件流的文件名 mode - 确定文件访问模式的空终止字符串 stream - 要修改的文件流 **返回值：**成功时为 stream 值的副本，失败时为空指针。 freopen 函数为已经打开的流附加上一个不同的文件。最常见的用法是把文件与一个标准流(stdin, stdout, stderr)相关联。例如，为了使程序向文件 foo 中写数据： 在关闭了先前（通过命令行重定向或之前的 freopen 函数调用）与 stdout 相关联的所有文件之后，freopen 函数打开文件 foo，并将其与 stdout 相关联。 5. 从命令行获取文件名 当正在编写的程序需要打开文件时，马上会出现一个问题：如何把文件名提供给程序呢？把文件名嵌入程序不太灵活，提示用户输入文件名的做法也很笨拙。通常最好的解决方法是让程序通过命令行获取文件的名字。 例如，执行 demo 程序时，将文件名放入命令行： 定义带有参数的 main 函数： 指针数组 argv 的元素如图： 程序：检查文件是否可以打开 下面程序判断文件是否存在，如果存在是否可以打开进行读入。在运行程序时，用户给出要检查的文件的名字： 然后程序将显示 ： 如果在命令行中录入的实际参数的数量不对，那么程序将显示出消息： 来提示用户 canopen 需要一个文件名。 canopen.c 注意，可以使用重定向来丢弃 canopen 的输出，并简单的测试它返回的状态值。 六 临时文件 现实世界中的程序经常需要产生临时文件，即只在程序运行时存在的文件。例如，C 编译器就常常产生临时文件（中间文件）。一旦程序完全通过了编译，就不再需要保留那些含有程序中间形式的文件了。 &#x3C;stdio.h> 提供了两个函数用来处理临时文件，即 tmpfile 函数和 tmpnam 函数。 FILE *tmpfile(void); 创建并打开一个临时文件。该文件作为二进制文件、更新模式（如同为 fopen 以 \"wb+\" 模式）打开。该文件的文件名保证在文件系统中唯一。至少可以在程序的生存期内能打开 TMP_MAX 个文件（此极限可能与 tmpnam 共享，并可能为 FOPEN_MAX 所进一步限制）。 **返回值：**指向与文件关联的文件流的指针，或若出现错误则为空指针。 该临时文件一直存在，除非关闭它或程序终止。函数返回文件指针，此指针可以用于稍后访问该文件： tmpfile 虽然容易使用，但是有两个缺点： 无法知道 tmpfile 函数创建的文件名 我发在以后决定使文件成为永久性的。 如果这些限制产生了问题，可以使用 fopen 函数产生临时文件。为了避免此文件和前面已经存在的文件具有相同的名字，所以就需要一种方法来产生新的文件名。这就是 tmpnam 函数出现的原因。 char *tmpnam( char *filename ); 创建独有的合法文件名（长度不长于 L_tmpnam ）并将它存储于 filename 所指向的字符串"},{"title":"标准库","slug":"c-modern-approach/23-standard-library","permalink":"/kb/posts/c-modern-approach/23-standard-library","category":"c-modern-approach","description":"Perhaps if we wrote programs from childhood on, as adults we'd be able to read them. [^1]","date":"2026-06-16T00:00:00.000Z","content":"标准库 Perhaps if we wrote programs from childhood on, as adults we'd be able to read them. 1 目录 [TOC] 一 标准库的使用 C89 标准库总共划分成 15 个部分，每个部分用一个头描述。C99 新增了 9 个头，总共有 24 个。 C89 &#x3C;assert.h> &#x3C;locale.h> &#x3C;stddef.h> &#x3C;ctype.h> &#x3C;math.h> &#x3C;stdio.h> &#x3C;errno.h> &#x3C;setjmp.h> &#x3C;stdlib.h> &#x3C;float.h> &#x3C;signal.h> &#x3C;string.h> &#x3C;limits.h> &#x3C;stdarg.h> &#x3C;time.h> C99 新增 &#x3C;complex.h> &#x3C;stdint.h> &#x3C;fenv.h> &#x3C;tgmath.h> &#x3C;inttype.h> &#x3C;wchar.h> &#x3C;iso646.h> &#x3C;wctype.h> &#x3C;stdbool.h> 大多数编译器都会使用更大的库，其中包含很多上表中没有的头。额外添加的头当然不属于标准库的范畴，所以我们不能假设其他的编译器也支持这些头。这类头通常提供些针对特定机型或特定操作系统的函数(这也解释了为什么它们不属于标准库)，它们可能会提供允许对屏幕或键盘做更多控制的函数。用于支持图形或窗口界面的头也是很常见的。 标准头主要由 函数原型、类型定义以及宏定义 组成。如果我们的文件中调用了头中声明的函数，或是使用了头中定义的类型或宏，就需要在文件开头将相应的头包含进来。当一个文件包含多个标准头时， #include 指令的顺序无关紧要。多次包含同一个标准头也是合法的。 1. 对标准库 中所用名字的限制 任何包含了标准头的文件都必须遵守两条规则。 第一， 该文件不能将头中定义过的宏的名字用于其他目的 。例如，如果某个文件包含了&#x3C;stdio.h>,就不能重新定义NULL了，因为使用这个名字的宏已经在&#x3C;stdio.h>中定义过了。 第二， 具有文件作用域的库名(尤其是typedef名)也不可以在文件层次重定义 。因此，一旦文件包含了&#x3C;stdio.h>，由于&#x3C;stdio.h>中已经将size_ t定义为typedef名，那么在文件作用域内都不能将size_ t重定义为任何标识符。 上述这些限制是显而易见的，但C语言还有一些其他的限制，可能是你想不到的。 由一个下划线和一个大写字母开头或由两个下划线开头的标识符 是为标准库保留的标识符。程序不允许为任何目的使用这种形式的标识符。 由一个下划线开头的标识符被保留用作具有文件作用域的标识符和标记 。除非在函数内部声明，否则不应该使用这类标识符。 在标准库中所有具有外部链接的标识符被保留用作具有外部链接的标识符 。特别是所有标准库函数的名字都被保留。因此，即使文件没有包含&#x3C;stdio.h>,也不应该定义名为printf的外部函数，因为在标准库中已经有一个同名的函数了。这些规则对程序的所有文件都起作用，不论文件包含了哪个头。虽然这些规则并不总是强制性的，但不遵守这些规则可能会导致程序不具有可移植性。 上面列出的规则不仅适用于库中现有的名字，也适用于留作未来使用的名字。至于哪些名字是保留的，完整的描述太冗长了，你可以在C标准的 “future library directions\" 中找到。例如，C保留了以str和一个小写字母开头的标识符，从而具有这类名字的函数就可以被添加到 &#x3C;string.h> 头中。 2. 使用宏隐藏的函数 C 程序员经常会用带参数的宏来替代小的函数，这在标准库中同样很常见。C 标准允许在头中定义与库函数同名的宏，为了起到保护作用，还要求有实际的函数存在。因此，对于库的头，声明一个函数并同时定义一个有相同名字的宏的情况并不少见。 我们已经见过宏与库函数同名的例子。getchar 是声明在&#x3C;stdio.h>中的库函数，它具有如下原型: &#x3C;stdio .h> 通常也把 getchar 定义为一一个宏: 在默认情况下，对 getchar 的调用会被看作宏调用(因为宏名会在预处理时被替换)。在大多数情况下，我们喜欢使用宏来替代实际的函数，因为这样可能会提高程序的运行速度。然而在某些情况下，我们可能需要的是一个真实的函数，可能是为了尽量缩小可执行代码的大小。 如果确实存在这种需求，我们可以使用 #undef 指令来删除宏定义。例如，我们可以在包含了&#x3C;stdio.h>后删除宏getchar的定义: 即使 getchar 不是宏，这样的做法也不会带来任何坏处，因为当给定的名字没有被定义成宏时，#undef指令不会起任何作用。 此外，我们也可以通过给名字加圆括号来禁用个别宏调用: 三 C89 &#x26; C99 标准宏概状 大家可以下去自行去看一看上面标准头表中的头都是主要用来做什么的，它们都有什么函数原型，类型定义或宏定义。 四 了解两个简单的头 前面我们已经了解了 &#x3C;string.h> ， &#x3C;stdio.h> 大部分函数和 &#x3C;stdlib.h> 中的一些函数。现在开始，我们就要继续了解一些常用的头中的函数。今天我们要学习的头是 &#x3C;stddef.h> 和 &#x3C;stdbool.h> 。 1. &#x3C;stddef.h> 常用定义 stddef.h 头提供了常用类型定义，但没有声明任何函数。定义的类型包括一下几个： ptrdiff_t 当进行指针相减运算时，其结果的类型。 size_t sizeof 运算符返回的类型 wchar_t 一种足够强大的，可以用于表示所有支持的地区所有字符的类型。 stddef.h 头中还定义了两个宏。 一个是 NULL ，用来表示空指针。 另一个宏是 offsetof 需要两个参数：类型（结构类型）和成员指示符（结构的一个成员）。offsetof 会计算计算结构起点到指定成员间的字节数。 考虑下面的结构: offsetof(struct s, a) 的值一定是0，C 语言确保结构的第一个成员的地址与结构自身地址相同。我们无法确定地说出 b 和 c 的偏移量是多少。一种可能是 offsetof(structs, b) 是1 (因为 a 的长度是一个字节)， 而 offsetof(struct s, c) 是9 (假设整数是32位)。然而，一些编译器会在结构中留下一些空洞，从而会影响到 offsetof 产生的值。例如，如果编译器在a后面留下了3个字节的空洞，那么 b 和 c 的偏移量分别是4和12。但这正是offsetof宏的魅力所在:对任意编译器，它都能返回正确的偏移量，从而 使我们可以编写可移植的程序。 offsetof有很多用途。例如，假如我们需要将结构 s 的前两个成员写入文件，但忽略成员 c。我们不使用 fwrite 函数来写 sizeof(struct s)个字节，因为这样会将整个结构写入，而只要写 offsetof(struct s, c) 个字节。 最后一点:一些在 &#x3C;stddef .h> 中定义的类型和宏在其他头中也会出现。(例如， NULL宏不仅在C99的头&#x3C;wchar .h>中定义，在&#x3C;locale.h>、&#x3C;stdio.h>、&#x3C;stdlib.h>、&#x3C;string.h>和&#x3C;time.h>中也有定义。)因此，只有少数程序真的需要包含&#x3C;stddef .h>) 2. stdbool.h 布尔类型和值 stdbool.h 头定义了 4 个宏： bool （定义为 _Bool ） true （定义为 1） false （定义为 0） __bool_true_false_are_defined （定义为 1） 在自己定义 bool，true，false 之前可以使用预处理指令（#if 或 #endif）来测试这个宏。 参考资料：《C语言程序设计：现代方法》 Footnotes 从童年开始写程序，长大了就能读懂了。 Epigrams on Programming 编程警句 ↩"},{"title":"错误处理","slug":"c-modern-approach/24-error-handling","permalink":"/kb/posts/c-modern-approach/24-error-handling","category":"c-modern-approach","description":"C语言错误处理机制：assert、errno、信号处理与setjmp","date":"2026-06-16T00:00:00.000Z","content":"错误处理 There will always be things we wish to say in our programs that in all known languages can only be said poorly. 1 目录 错误处理 零 前言 编写无错程序的方法有两种，但只有第三种写程序的方法才行得通。学习 C 语言的学生所编写的程序在遇到异常输入时经常无法正常运行，但真正商业用途的程序却必须“非常强壮”能够从错误中恢复正常而不至于崩溃。 为了使程序非常强壮，我们需要能够预见程序执行时可能遇到的错误，包括对每个错误进行检测，并提供错误发生时的合适行为。 本章讲述两种在程序中检测错误的方法：调用 assert 宏以及测试 errno 变量；如何检测并处理称为信号的条件，一些信号用于表示错误；最后探讨 setjmp/longjmp 机制，它们经常用于响应错误。 错误的检测和处理并不是C语言的强项。C语言对运行时错误以多种形式表示，而没有提供一种统一的方式。而且，在 C 程序中，需要由程序员编写检测错误的代码。因此，很容易忽略一些可能发生的错误。一 旦发生某个被略掉的错误，程序经常可以继续运行，虽然不是很好。C++、 Java 和 C# 等较新的语言具有“异常处理\"特性，可以更容易地检测和响应错误。 一 &#x3C;assert.h> 诊断 assert 定义在 &#x3C;assert.h> 中。它使程序可以监控自己的行为，并尽早发现可能会发生的错误。 虽然 assert 实际上是一个宏， 但它是按照函数的使用方式设计的。assert有一个参数，这个参数必须是一种“断言”一个我们认为在正常情况 下定为真的表达式。 每次执行 assert时，它都会检查其参数的值。如果参数的值不为 0， assert什么也不做；如果参数 的值为 0, assert 会向 stderr (标准错误流) 写条消息， 并调用 abort 函数 终止程序执行。 例如，假定文件 demo.c 声明了一个长度为 10 的数组 a，我们关心的是 demo.c 程序中的语句： 可能会由于 i 不在 0- 9 之间而导致程序失败。可以在给 a[i] 赋值前使用 assert 宏检查这种情况： 如果的值小于0或者大于等于10，程序在显出类似下面的消息后会终止： C99 对asset做了两处小修改。C89 标准指出，assert 的参数必须是 int 类型的。C99 放宽了要求，允许参数为任意标量类型(C99 的原型中出现了单词 scalar)。例如，现在参数可以为浮点数或指针。 此外， C99 要求失败的 assert 显示其所在的函数名。(C89只要求 assert 以文本格式显示参数，源文件及源文件中的行号。) C99 建议的消息格式为： 根据编译器的不同，assert 生成的消息格式也不尽相问，但它们都应包含标准要求的信息。例如，GCC编译器在上述情况下给出如下的消息： assert 有个缺点: 因为它引入了额外的检查， 因此会增加程序的运行时间。偶尔使用一下 assert 可能对程序的运行速度没有很大影响，但在实时程序中，这么小的运行时间增加可能也是无法接受的。因此许多程序员在测过程中会使用 assert，但当程序最终完成时就会禁止 assert 。要禁止 assert 很容易，只需要在包含 &#x3C;assert.h> 之前定义宏 NDEBUC 即可。 NDEBUC的值不重要，只要定义了 NDEBUG 宏即可。一旦之后程序又有错误发生，可以去掉 NDEBUG 宏的定义来重新启用 assert 。 注意： 不要在 assert 中使用有副作用的表达式(包括函数调用)。一旦后来某天禁止了 assert ,这些表达式将不再会被求值。考虑下面的例子: 一旦定义了 NDEBUG， assert 会被忽略并且 malloc 不会被调用。 二 &#x3C;errno.h> ：错误 标准库中的一些函数通过向 &#x3C;errno . h> 中声明的 int 类型 errno 变量存储一个错误码(正整数)来表示有错误发生。(errno可能实际上是个宏。如果确实是宏，C标准要求它表示左值，以便可以像变量一样使用。) 大部分使用 errno 变量的函数集中在 &#x3C;math.h> ,但也有一些在标准库的其他部分。 假设我们需要使用一个库函数，该库函数通过给 errno 赋值来产生程序运行出错的信号。在调用这个函数之后，我们可以检查errno的值是否为零。如果不为零，则表示在函数调用过程中有错误发生。举例来说，假如需要检查 sqrt 函数的调用是否出错，可以使用类 似下面的代码: 当使用 errno 来检测库的数调用中的错误时，在 函数调用前将 errno 置零 非常重要。虽然在程序刚开始运行时 errno 的值为零，但有可能在随后的函数调用中已经被改动了。 库函数不会将 errno 清零，这是程序需要做的事情。 当错误发生时，向 errno 中存储的值通常是 EDOM 或 ERANGE. (这两个宏都定义在 &#x3C;errno.h> 中。) 这两个值代表调用数学函数时可能发生的两种错误： **定义域错误(EDOM)：**传递给函数的一个参数超出了函数的定义域。例如，用负数作为 sqrt 的参数就会导致定义域错误。 取值范围错误（ERANGE）: 函数返回值太大，无法用返回类型表示。 一些函数可能会同时导致这两种错误。我们可以用 errno 分别与 EDOM 和 ERANGE 比较，然后确定究竟发生了那种错误。 perror 函数和 strerror 函数 void perror(const char* s); &#x3C;stdio.h> char *stderror(int errnum); &#x3C;string.h> 这两个函数都不属于 &#x3C;errno.h> 。 当库函数向 errno 存储了一个非零值时，可能希望显示一条描述这种错误的信息。 一种实现方式是调用 perror 函数，他会按顺序显示一下信息： 调用 perror 的参数 一个冒号 一个空格 一条出错消息，消息内容根据 errno 的值决定 一个换行符。perror 函数会输出到 stderr 流而不是标准输出。 下面是一个使用 perror 的例子： 如果 sqrt 调用因为定义域错误而失败，perror 会打印如下输出： perror 函数在 sqrt error 后显示处的错误消息是由实现定义的。在这个例子中，Numerical argument out of domain. 是与 EDOM 错误相对应的消息。ERANGE 错误通常会对应不同的消息。 stderror 函数属 &#x3C;string.h>。当以错误码为参数调用 stderror 时，函数会返回一个指针，它指向一个描述这个错误的字符串。例如，调用： 可能会显示： stderror 的值通常是 errno 的值，但以任意整数作为参数时 stderror 都能返回一个字符串。 stderror 和 perror 紧密相关。如果 stderror 的参数为 errno，那么 perror 所显示的出错消息与 stderror 所返回的消息相同。 更多例子 输出： 三 &#x3C;signal.h> ：信号处理 &#x3C;signal.h> 提供了处理异常情况(称为信号)的工具。信号有两种类型： 运行时错误 (例如除以0)和 发生在程序以外的事件 。例如，许多操作系统都允许用户中断或终止正在运行的程序，C语言把这些事件视为信号。当有错误或外部事件发生时，我们称产生了一个信号。大多数信号是异步的：它们可以在程序执行过程中的任意时刻发生，而不仅是在程序员所知道的特定时刻发生。由于信号可能会在任何意想不到的时刻发生，因此必须用一种独特的方式来处理它们。 本节按 C 标准中的描述来介绍信号。这里对信号谈得很有限，但实际上信号在UNIX中的作用很大。有关UNIX信号的信息，见参考文献中列出的UNIX编程书。 1. 信号宏 &#x3C;signal.h> 中定义了一系列的宏，用于表示不同的信号。每个宏的值都是正整数常量。C 语言的实现可以提供更多的信号宏，只要宏的名字以 SIG 和一个大写字母开头即可。 常量 解释 SIGTERM 发送给程序的终止请求 SIGSEGV 非法内存访问（段错误） SIGINT 外部中断，通常为用户所发动 SIGILL 非法程序映像，例如非法指令 SIGABRT 异常终止条件，例如 abort() 所起始的 SIGFPE 错误的算术运算，如除以零 C 标准不要求表中的信号都自动产生，因为对于某个特定的计算机或操作系统，不是所有信号都是有意义的。大多数 C 的实现都支持其中的一部分。 2. signal 函数 void (*signal( int sig, void (*handler) (int))) (int); &#x3C;signal.h> 提供了两个函数： raise 和 signal 。 这里先讨论 signal 函数，它会安装一个信号处理函数，以便将来给定的信号发生时使用。signal函数的使用比它的原型看起来要简单得多。它的第一个参数是特定信号的编码，第二个参数是一个指向会在信号发生时处理这信号的函数的指针。例如，下面的signal 函数调用为SIGINT信号安装了一个处理函数: handler 就是信号处理函数的函数名。一旦随后在程序执行过程中出现了 SIGINT 信号，handler 函数就会自动被调用。 每个信号处理函数都必须有一个 int 类型的参数，且返回类型为void。当个特定的信号产生并调用相应的处理函数时，信号的编码会作为参数传递给处理函数。知道是哪种信号导致了处理函数被调用是十分有用的，尤其是，它允许我们对多个信号使用同一个处理函数。 信号处理函数可以做许多事。这可能包含忽略该信号、执行一些错误恢复或终止程序。然而，除非信号是由调用 abort 函数或 raise函数引发的，否则信号处理函数不应该调用库函数或试图使用具有静态存储期限的变量。(但这些规则也有例外。) 一旦信号处理函数返回，程序会从信号发生点恢复并继续执行，但有两种例外情况: (1)如果信号是 SIGABRT ,当处理函数返回时程序会(异常地)终止: (2)如果处理的信号是 SIGFPE ,那么处理函数返回的结果是未定义的。(也就是说，不要处理它。) 虽然 signal 函数有返回值，但经常被丢弃。返回值是指向指定信号的前一个处理函数的指针。如果需要，可以将它保存在变量中。特别是，如果打算恢复原来的处理函数，那么就需要保留 signal 函数的返回值： 这条语句将 handler 函数安装为 SIGINT 的处理函数，并将指向原来的处理函数的指针保存在变量 orig_handler 中。如果要恢复原来的处理函数，我们需要使用下面的代码: 3. 预定义的信号处理函数 除了编写自己的信号处理函数，还可以选择使用 &#x3C;signal .h> 提供的预定义的处理函数。有两个这样的函数，每个都是用宏表示的。 SIG_DFL ： SIG_DFL 按“默认”方式处理信号。可以使用下面的调用安装 SIG_DFL: 调用 SIG_DFL 的结果是由实现定义的，但大多数情况下会导致程序终止。 SIG_IGN ：调用 指明随后当信号 SIGINT 产生时，忽略该信号。 除了 SIG_DFL 和 SIG_IGN， &#x3C;signal .h> 可能还会提供其他的信号处理函数；其函数名必须是以 SIG_ 和一个大写字母开头。当程序刚开始执行时，根据不同的实现，每个信号的处理函数都会被初始化为 SIG_ DFL 或 SIG_ IGN。 &#x3C;signal.h> 还定义了另一个宏：SIG_ ERR，它看起来像是个信号处理函数。实际上，SIG_ERR 是用来在安装处理函数时检测是否发生错误的"},{"title":"初识 动态内存分配","slug":"c-notes/01-understanding-malloc","permalink":"/kb/posts/c-notes/01-understanding-malloc","category":"c-notes","description":"动态内存分配的概念、malloc函数的使用与注意事项","date":"2026-06-16T00:00:00.000Z","content":"初识 动态内存分配 动态内存分配的引入 初学数组的时候，有一个问题经常困扰着我，就是：我们可不可以 自己在程序里定义一个数组的大小 而不是在函数开头先声明一个很大的数组，然后仅仅使用它的一小部分？ 请看下面的程序： 我们需要一个大小为 N ( N &#x3C; 1000)的数组,我们通常这么写： 每次这么写我都觉得自己在绕远路，为什么就不能直接把输入的变量 N 当作数组的大小直接使用？ 比如这样： arr[N] ，但是很遗憾，每次编译器都把你扼杀在程序编译之前！ C99才可以用变量做数组定义的大小 并且可以在程序中随时声明变量。（C99前我们需要在函数的最前面的区域对所有变量进行声明） 如果我不想用上面那种笨笨的办法，又没有支持C99的编译器，我该怎么办？ 可以这么做： int* arr = (int*)malloc(sizeof(int) * N) sizeof(int) 代表数组中每个元素的类型 N 代表数组的元素个数 所以malloc的意义是向 堆区 要了一块 sizeof(int) * N 这么大的空间 malloc 与 free ——好哥俩 malloc 头文件 ： stdlib 原型 ： void* malloc(size_t size) 所以需要根据实际你需要的类型对其强制类型转换 返回值 ： 成功时，返回指向新分配内存的指针。为避免内存泄漏，必须用 free() 或 realloc() 解分配返回的指针。 失败时，返回空指针（NULL） 参数 ：size - 要分配的字节数 定义 分配 size 字节的未初始化内存。 若分配成功，则返回为任何拥有基础对齐的对象类型对齐的指针。 —— 若 size 为零，则 malloc 的行为是实现定义的。例如可返回空指针。亦可返回非空指针；但不应当解引用这种指针，而且应将它传递给 free 以避免内存泄漏。 更多关于malloc free 头文件 ： stdlib 原型 ： void free( void* ptr ); 参数 ：指向要解分配的内存的指针 返回值 ：无 此函数接收空指针（并对其不处理）以减少特例的数量。不管分配成功与否，分配函数返回的指针都能传递给 free() —— 这是什么意思？意思就是malloc与free成对出现，不要忘记写free哦。 定义： 解分配之前由 malloc() 、 calloc() 、 aligned_alloc (C11 起) 或 realloc() 分配的空间。 —— 若 ptr 为空指针，则函数不进行操作 。[^1] —— 若 ptr 的值 不等于之前从 malloc() 、 calloc() 、 realloc() 或 aligned_alloc() (C11 起) 返回的值 [^2]，则行为未定义。 —— 若 ptr 所指代的内存区域已经被解分配[^3] ，则行为未定义，即是说已经以ptr 为参数调用 free() 或 realloc() ，而且没有后继的 malloc() 、 calloc() 或 realloc() 调用以 ptr 为结果。 —— 若在 free() 返回后通过指针 ptr 访问内存[^4] ，则行为未定义（除非另一个分配函数恰好返回等于 ptr 的值）。 更多关于free free() ：将申请来的空间的 首地址 还给“系统”，只要申请到了空间就 一定要归还 毕竟有借有还，再借不难嘛 解读 free 注释1：释放空指针有何意义？ 我们在声明一个指针时，一般把它初始化为0，也就是NULL。 —— 这样做的好处是，如果我们在后面的程序中没有让这个指针指向一块具体的空间，这个指针不会是野指针，方便我们用来判断。比如 if(p != NULL) —— 我们还知道，当malloc失败时返回的是 NULL 所以我们一开始写上free是好习惯，因为我们不知道我们会不会用到我们声明的指针，也不知道malloc能不能成功 这时候，free空指针就是有意义的了 注释2：molloc申请到的指针 与 free要释放的指针保持一致 注释3：free释放空间后，被释放的指针成为野指针，不能直接使用它 注释4：不能多次释放同一次malloc申请的地址 现在我们就可以改进我们上面的程序啦！ 什么？不是改进吗？怎么行数反而变多了？ ![]( https://img-blog.csdnimg.cn/20200204003505997.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzQ0OTU0MDEw,size_16,color_FFFFFF,t_70 =300x300) 测测你能给系统分配多大空间？ 如果忘记了free？ 我们一次程序中可以申请的内存是有限的。 如果你只是平时写简单的程序，写完就关闭，退出去了，这时忘记了free的话，不会对任何人造成影响，因为操作系统有清除曾使用的内存的机制 但是如果是一个持续运行的服务器呢？堆区中所有的空间都被你申请了呢？ free的常见问题 申请了没有free -> 长时间运行内存逐渐下降 free 后再free 地址变更后，直接去free 小测试： 1.对于以下的代码段，正确的说法是： A:最终程序会因为没有空间了而退出 B:最终程序会因为向0地址写入而退出 C:程序会一直运行下去 D:程序不能被编译 2.对于以下代码段： 当 sizeof(int) = 4 时，以下说法正确的是： A：因为第三行的错误不能编译 B：因为第三行的的错误运行时崩溃 C：输出5 D：输出20 3.使用malloc就可以做出运行时可以随时改变大小的数组 A：√ B：❌ 查看答案可在后台回复： 2020 0204 查看答案哦 欢迎各位与我交流讨论！"},{"title":"C语言还值得学吗","slug":"c-notes/02-is-c-worth-learning","permalink":"/kb/posts/c-notes/02-is-c-worth-learning","category":"c-notes","description":"C语言的学习价值、与其他语言的关系及学习建议","date":"2026-06-16T00:00:00.000Z","content":"C 语言还值得学吗？ 答案是肯定的。 第一，学习C有助于更好的理解C++，Java，C#，Perl以及其他基于C的特性的语言。第一开始就学习其他语言的程序员往往不能很好的掌握继承自C语言的基本特性。 第二，目前仍有许多C程序，我们需要读懂并维护这些代码。 第三，C语言仍广泛应用于新软件的开发，特别是在内存或处理能力受限的情况下以及需要使用C语言简单特性的地方。 C 语言会过时吗？ 对所有的编程语言，他们的最后的目的其实就是两种： 提高硬件的运行效率和提高程序员的开发效率 。遗憾的是，这两点是不可能并存的！你只能选一样。在提高硬件的运行效率这一方面，C语言没有竞争者！举个简单的例子，实现一个列表，C语言用数组int a[3]，经过编译以后变成了（基地址＋偏移量）的方式。对于计算机来说，没有运算比加法更快，没有任何一种方法比（基地址＋偏移量）的存取方法更快。C语言已经把硬件的运行效率压缩到了极致。这种设计思想带来的问题就是易用性和安全性的缺失。例如，你不能在数组中混合保存不同的类型，否则编译器没有办法计算正确的偏移量。同时C语言对于错误的偏移量也不闻不问，这就是C语言中臭名昭著的越界问题。C语言自诩的“相信程序员”都是漂亮的说辞，它的唯一目的就是快，要么飞速的运行，要么飞速的崩溃。C语言只关心程序飞的高不高，不关心程序猿飞的累不累。就是这样！ 伴随着嵌入和实时系统的兴起，AI，机器人，自动驾驶等。这些都是C语言的核心应用，而且在这种应用上面，C语言没有竞争者。所以我感觉C语言会稳定在自己核心的应用中，并开始逐步回升。但是Java语言我个人不乐观。小型和灵活性上，Python更胜一筹。一行python代码后，你根本不知道自己还是不是duck类型？平台领域，每个平台都推出自己专属的语言。Windows会继续支持C#，苹果偏爱Swift, Android推出Kotlin，Google用go。Java宣称自己可以自由到每家做客，但是无论是到谁家，都会发现客厅里面坐着一个亲儿子，这个时候自己这个干儿子多多少少有点尴尬。所以我猜测，最后Java会稳定在对跨平台有严格要求的，大型非实时应用上。 最后说点闲话，C++不会淘汰C语言。有了对象后你会发现再简朴的对象也耗费资源，而且有了对象以后，总是不由自主的去想继承这个事，一但继承实现了，你会发现继承带来的麻烦远超过你的想象。Java的发明人James被问到如果可以从新设计Java语言的话，第一个要做什么事？他说：“去掉对象”！作为一个已婚，有两个孩子的程序猿，我感同身受。如果大家感兴趣，我可以再写一个博客，聊聊C++和C的真实区别所在。 如果你看到这里，还什么都没记住。那就只记住一点：没人能预测未来。 －－－－－－－－－－－－－－－－－－－－－－－－－－ 全世界只需要五台电脑 －IBM创始人 640K内存足够了 －微软创始人 没必要在家里用电脑－DEC创始人 －－－－－－－－－－－－－－－－－－－－－－－－－－ 如果再有人对你说C语言已经过时了，最好自己思考一下，能求真最好，如果不能，至少要做到存疑。 C 语言的前世今生 1. 起源 C语言是贝尔实验室的 Ken Thompson, Dennis Ritchie 等人开发的 UNIX 操作系统的“副产品”。 与同时代的其他操作系统一样，UNIX 系统最初也是用汇编语言写的。用汇编语言编写的程序往往难以调试和改进，UNIX 操作系统也不例外。Thompson 意识到需要用一种高级的编程语言来完成 UNIX 系统未来的开发，于是他设计了一种小型的 B语言。Thompson 的 B语言是在 BCPL语言（20世纪60年代中期产生的一种系统编程语言）的基础上开发的，而 BCPL语言又可以追溯到最早（且影响深远）的语言之一——Algol 60语言。 1970年，贝尔实验室为 UNIX 项目争取到了一台 PDP-11 计算机。当 B语言经过改进并能够在 PDP-11 计算机上成功运行后，Thompson 用 B语言重新编写了部分 UNIX 代码。 到了1971年，B语言已经明显不适合 PDP-11 计算机了，于是 Ritchie 着手开发 B语言的升级版。最初他将新开发的语言命名为 NB语言（意味New B），但是后来新语言越来越偏离 B语言，于是他将其改名为 C语言。 到1973年，C语言已经足够稳定，可以用来重新编写 UNIX 系统了。 2. 标准化 C语言在20世纪七十年代（尤其是1977年到1979）持续发展。这一时期出现了第一本有关 C语言的书。Brian Kernighan 和 Dennis Ritchie 合作编写的 The C Programming Language 于1978年出版，并迅速成为 C程序员必读的“圣经”。由于当时没有 C语言的正式标准，这本书就成为了事实上的标准，编程爱好者把它称为“K&#x26;R”或者“白皮书”。 随着C语言的迅速普及，一系列问题也接踵而至。首先， K&#x26;R 对一些语言特性描述得非常模糊，以至于不同编译器对这些特性会做出不同的处理。而且，K&#x26;R 也没有对属于 C语言的特性和属于 UNIX 系统的的特性进行明确的区分。更糟糕的是，K&#x26;R 出版后 C语言仍然在不断变化，增加了一些新特性并除去了一些旧特性。很快，C语言需要一个全面，准确的最新描述开始成为共识。 C89/C90 1983年，在美国国家标准协会（ANSI）的推动下（ANSI 于此年组建了一个委员会称为 X3Jll），美国开始制定本国的 C语言标准。 1988年完成并于1989年12月正式通过的 C语言标准成为 ANSI 标准 X3.159-1989。 1990年，国际标准化组织（ISO）通过了此项标准，将其作为 ISO/IEC 9899:1990 国际标准（中国国家标准为 GB/T 15272—1994）。 我们把这一C语言版本称为 C89 或 C90 ，以区别原始的 C语言版本。 委员会在制定的指导原则中的一条写道：保持 C 的精神。委员会在描述这一精神时列出了一下几点： 信任程序员 不要妨碍程序员做需要做的事 保持语言精炼简单 只提供一种方法执行一项操作 让程序运行更快，即使不能保持其可移植性 在最后一点上，标准委员会的用意是：作为实现，应该针对目标计算机来定义最合适的某特定操作，而不是强加一个抽象，统一的定义。在学习 C语言的过程中，许多方面都反映了这一哲学思想。 C99 1995 年，C语言发生了一些改变。 1999年通过的 ISO/IEC 9899：1999 新标准中包含了一些更重要的改变，这一标准所描述的语言通常称为 C99 此次改变，委员会的用意不是在C语言中添加新的特性，而是为了达到新的目标。 支持国际化编程 。如：提供多种方法处理国际字符集 调整现有实践致力于解决明显的缺陷 。因此，在遇到需要将 C移至64位处理器时，委员会根据现实生活中处理问题的经验来添加标准。 为 适应科学和工程项目中的关键计算 ，提高 C 的适应性，让 C 比 FORTRAN 更有竞争力。 其他方面的改变则更为保守，如，尽量让C90，C++兼容，让语言在概念上保持简单。 虽然改标准已经发布了很长时间，但 并非所有编译器都完全支持C99 的所有改动。因此，你有可能发现 C99 的一些改动在自己的系统中不可用，或者需要改变编译器的设置才可用。 C11 2011年， C11 标准问世。 那些基于 C 的语言，你知道吗？ C++：包含所有C的特性 Java：基于C++，所以也继承了C的许多特性 C#：由C++于java发展起来的较新的语言 Perl：最初是一种简单的脚本语言，在发展过程中采用了C的许多特性 Python ... C 语言的优缺点 与其他任何一种编程语言一样，C语言也有自己的优缺点。这些优缺点都源于该语言的最初用途（编写操作系统和其它系统软件）和它自身的基础理论体系。 C语言是一种底层语言 为了适应系统编程的需要，C语言提供了对机器级概念（例如，字节和地址）的访问，而这些都是其他编程语言试图隐藏的内容。 C语言是一种小型语言 与许多其他编程语言相比，C语言提供了一套更有限特性集合。（在K&#x26;R第二版的参考手册中仅用49页就描述了整个C语言。）为了使特性较少，C语言在很大程度上依赖一个标准函数的“库”。 C是一种包容性语言 C假设用户知道自己在干什么，因此它提供了比其他许多语言更广阔的自由度。此外，C语言不像其他语言那样强制进行详细的错误检查。 1. C语言的优点 C语言的众多优点解释了C语言为何如此流行。 高效 高效性是C语言与生俱来的优点之一。发明C语言就是为了编写那些以往由汇编语言编写的程序，所以对C语言来说，能够在有限的内存空间快速运行就显得至关重要。 可移植 当程序必须在多种机型（从个人计算机到超级计算机）上运行时，常常会用C语言来编写。 原因一：C语言没有分裂成不兼容的多种分支。这主要归功于C语言早期与UNIX系统的结合以及后来的ANSI/ISO标准。 原因二：C语言编译器规模小且容易编写，这使得它们得以广泛应用。 原因三：C语言的自身特性也支持可移植性（尽管它没有阻止程序员编写不可移植的程序）。 功能强大 C语言拥有一个庞大的数据类型和运算符集合，这个集合使得C语言具有强大的表达能力，往往寥寥几行代码就可以实现许多功能。 灵活 C语言最初设计是为了系统编程，但没有固有的约束将其限制在此范围内。C语言现在可以用于编写从嵌入式系统到商业数据处理的各种应用程序。 标准库 C语言的突出优点就是它具有标准库，该标准库包括了数百个可以用于输入/输出，字符串处理，储存分配以及其他实用操作的函数。 与UNIX的集成 C语言在与UNIX系统（包括Linux）结合方面特别强大。事实上，一些UNIX工具甚至假设用户是了解C语言的。 2. C语言的缺点 C语言容易隐藏错误 C语言的灵活性使得用它编程出错的概率极高。在用其他语言时可以发现的错误，C语言的编译器却无法检查到。更糟糕的是，C语言还包含大量不易察觉的隐患。 C程序可能难以理解 C程序的简明扼要与灵活性，可能导致程序员编写出除了自己别人无法读懂的代码。 C程序可能难以修改 如果在设计中没有考虑到维护的问题，那么C编写的大型程序可能很难修改。现代的编程语言通常提供“类”和“包”之类的语言特性，这样的特性可以把大的程序分解成许多更容易管理的模块。遗憾的是，C语言恰恰缺少这样的特性。 3. 高效的使用C语言 要高效的使用C语言，就需要利用C语言优点的同时尽量避免它的缺点，一下给出一些建议。 学习如何规避C语言的缺陷 使用软件工具使程序更可靠 利用现有的代码库 使用C语言的一个好处是其他许多人也在使用C。把别人编写好的代码用于自己的程序是一个非常好多主意。C代码通常被打包成库（函数的集合）。获取适当的库既可以大大减少错误，也可以节省很多编程工作。 采用一套切合实际的编码规范 良好的编码习惯和规范易于自己和他人对自己代码的阅读和修改。 避免“投机取巧”和极度复杂的代码 。C语言鼓励使用编程技巧。但是，过犹不及，不要对技巧毫无节制，最简单的解决方案往往也是最难理解的。 紧贴标准 大多数编译器都提供不属于 C89/C99 标准的特征和库函数。为了程序的可移植性，若非确有必要，最好避免这些特性和库函数。 为什么 C 语言难学？ 不同与JAVA和python，C语言面临的任务几乎都是要求实时，高速或者是嵌入的。例如医疗，军事，飞控，航天，金融等领域。举个栗子，NASA大部分软件要基于三个不同的时钟系统，自转（公转）时间，CPU的晶振时间和原子钟时间。一秒要分成500份，基于2毫秒的基础进行操作同步；同时用全球的原子钟时间均值对所有时钟系统调整。在这种环境下，JAVA那种“大约一分钟以后”的虚拟机管理方式一定是不行的。 所以我在NASA工作所接触的软件，几乎都是C语言编写的。可想而之，这种软件的开发难度，当你阅读这种程序代码的时候，你说C语言太难了，这是否有点不公平？ 其次是"},{"title":"关于整数类型存储的面试问题","slug":"c-notes/03-c-interview-questions","permalink":"/kb/posts/c-notes/03-c-interview-questions","category":"c-notes","description":"三道C语言经典面试题：整数存储、类型转换与指针运算","date":"2026-06-16T00:00:00.000Z","content":"关于整数类型存储的面试问题 以下三个问题大家可以先独立思考一下，看看如果真的面试官问你，你能不能正确的回答并清晰的讲出其中的原理。 问题 1 请问，printf 函数会打印出什么内容？并解释原因。 signed char 与 char 表示同一种类型，原理一样 问题 2 请问，printf 函数会打印出什么内容？并解释原因。 你想到了吗？ 我们还是按照上面的思路分析： 问题 3 请问，printf 函数会打印出什么内容？并解释原因。 神奇吗？并不神奇。 原因就在于“截断”时得到的二进制序列是一模一样的，后面的操做是相同的。 另外说一句，char 的范围是 -128 ~ 127，所以上面的 char 型变量 a 溢出了。 试着想想下面的 printf 函数又会输出什么呢？ 推荐阅读： 给你三个必须要学C语言的理由！"},{"title":"C语言文件输入输出","slug":"c-notes/04-c-file-io","permalink":"/kb/posts/c-notes/04-c-file-io","category":"c-notes","description":"C语言文件操作：fopen、fclose、fread、fwrite及格式化IO","date":"2026-06-16T00:00:00.000Z","content":"想看更好排版，可以看原文 点击看原文 文件 格式化的输入输出 printf % [flag] [width] [.prec] [hlL] type scanf % [flag] type printf flag 属性一般与 width 属性结合 Flag 含义 - 左对齐 + 在正数放 + 0 在前面填充 0 例1 例2 + 可以让正数打印出符号，负数的符号自动会打印出来 例3 *有的编译器不允许 - 0 *这样的语法，因为 - 已经表示左对齐了， 0 就没有意义了 width.prec width.prec 含义 number . number 总共的输出占几位 . 小数点后占几位 *.* 下一个参数是字符数或小数点后的位数 例1 例2：融合一下 小数点 . 也是占位数的 例3： 给了我们用参数控制格式的途径，可以用变量来改变输出的格式 hlL 格式 修饰类型 含义 hh 单个字节 h short l long ll long long L long double 12345 的 16 进制数是：3039 39 的十进制是 57 格式 type 表示 type 表示 i&#x26;d int g float u unsigned int G float o 八进制 a&#x26;A 十六进制浮点 x 十六进制 c char X 大写的十六进制 s 字符串 f&#x26;F float p 指针 e&#x26;E 指数 n 读入/写出的个数 scanf ：% [flag] type flag 属性 flag 含义 * 跳过 数字 最大字符数 hh char h short l long ,double ll long long L long double 例： 程序输出如下： 输入：123 456 type属性 type 用于 d int i 整数（10进制，8进制或16进制） u unsigned int o 八进制 x 十六进制 a,e,f,g float c char s 字符串 [...] 允许的字符 p 指针 printf 与 scanf 的返回值 scanf :读入的项目（item）数 printf : 输出的字符数 有什么用？ 再由严格要求的程序中，应该判断每次调用 scanf 或 printf 的返回值，从而了解运行中的程序是否出现了问题 例如： 文件输入输出 &#x3C; 与 > 来重定向 &#x3C; 重定向输入 > 重定向输出 我们用 linux 操作系统为例： ![](/static/c/c-aHR0cHM6Ly9pbWct.png) 1.写一个c文件并完成编译 ![](/static/c/c-aHR0cHM6Ly9pbWct.png) 2.这是标准的输入输出 ![](/static/c/c-aHR0cHM6Ly9pbWct.png) 3.我们先创建一个文件 read.out，并写入 1234 ![](/static/c/c-aHR0cHM6Ly9pbWct.png) 4.用 read.out 作为输入，向 write.out 中写入程序运行结果 用程序打开文件 fopen FILE *fopen( const char *filename, const char *mode ) ;(C99 前) FILE *fopen( const char *restrict filename, const char *restrict mode ) ;(C99 起) 头文件 ：stdio.h 参数 ： filename - 关联到文件系统的文件名 mode - 确定访问模式的空终止字符串 返回值 ： 若成功，则返回指向新文件流的指针。流为完全缓冲，除非 filename 表示一个交互设备。错误时，返回空指针 简单理解 r 打开只读 r+ 打开读写，从文件头开始 w 打开只写。如果不存在则新建，如果存在就清空 w+ 打开读写。如果不存在则新建，如果存在清空 a 打开追加。如果不存在则新建，如果存在则从文件尾开始追加 x 后附于上面。表示如果文件已存在则不能打开 fclose int fclose( FILE *stream ) **头文件：**stdio.h 参数： stream - 需要关闭的文件流 返回值： 成功时为 0 ，否则为 EOF 。 定义： 关闭给定的文件流。冲入任何未写入的缓冲数据到 OS 。舍弃任何未读取的缓冲数据。 无论操作是否成功，流都不再关联到文件，且由 setbuf 或 setvbuf 分配的缓冲区若存在，则亦被解除关联，并且若使用自动分配则被解分配。 若在 fclose 返回后使用指针 stream 的值则行为未定义。 scanf系 · int scanf( const char *format, ... ) ;(C99 前) int scanf( const char *restrict format, ... ) ;(C99 起) (2) int fscanf( FILE *stream, const char *format, ... ) ;(C99 前) int fscanf( FILE *restrict stream, const char *restrict format, ... ) ;(C99 起) (3) int sscanf( const char *buffer, const char *format, ... ) ;(C99 前) int sscanf( const char *restrict buffer, const char *restrict format, ... ) ;(C99 起) 定义 从各种资源读取数据，按照 format 转译，并将结果存储到指定位置。 从 stdin 读取数据 从文件流 stream 读取数据 从空终止字符串 buffer 读取数据。抵达字符串结尾等价于 fscanf 的抵达文件尾条件 参数： stream - 要读取的输入文件流 buffer - 指向要读取的空终止字符串的指针 format - 指向指定读取输入方式的空终止字符串的指针。 返回值： 成功赋值的接收参数的数量（可以为零，在首个接收用参数赋值前匹配失败的情况下），者若输入在首个接收用参数赋值前发生失败，则为EOF。 printf系 头文件： stdio.h (1) int printf( const char *format, ... ) (C99 前) int printf( const char *restrict format, ... ) ;(C99 起) (2) int fprintf( FILE *stream, const char *format, ... ) ;(C99 前) int fprintf( FILE *restrict stream, const char *restrict format, ... ); (C99 起) (3) int sprintf( char *buffer, const char *format, ... ) ;(C99 前) int sprintf( char *restrict buffer, const char *restrict format, ... ) ;(C99 起) (4) int snprintf( char *restrict buffer, int bufsz, const char *restrict format, ... ) ;(C99 起) 定义： 从给定位置加载数据，转换为字符串等价物，并写结果到各种池。 写结果到 stdout 。 写结果到文件流 stream 。 写结果到字符串 buffer 。 写结果到字符串 buffer 。至多写 buf_size - 1 个字符。产生的字符串会以空字符终止，除非 buf_size 为零。若 buf_size 为零，则不写入任何内容，且 buffer 可以是空指针，然而依旧计算返回值（会写入的字符数，不包含空终止符）并返回。 参数： stream - 要写入的输出文件流 buffer - 指向要写入的字符串的指针 bufsz - 最多会写入 bufsz - 1 个字符，再加空终止符 format - 指向指定数据转译方式的空终止多字节字符串的指针。 返回值： 1,2) 传输到输出流的字符数，或若出现输出错误或编码错误（对于字符串和字符转换指定符）则为负值。 写入到 buffer 的字符数（不计空终止字符），或若输出错误或编码错误（对于字符串和字符转换指定符）发生则为负值。 假如忽略 bufsz 则本应写入到 buffer 的字符数（不计空终止字符），或若出现输出错误或编码错误（对于字符串和字符转换指定符）则为负值。 标准代码： 例： 二进制文件 fread size_t fread( void *restrict buffer, size_t size, size_t count, FILE *restrict stream ); 定义于头文件 &#x3C;stdio.h> 参数 ： buffer - 指向要读取的数组中首个对象的指针 size - 每个对象的字节大小 count - 要读取的对象数 stream - 读取来源的输入文件流 返回值 ： 成功读取的对象数，若出现错误或文件尾条件，则可能小于 count 。 若 size 或 count 为零，则 fread 返回零且不进行其他动作。 fread 不区别文件尾和错误，而调用者必须用 feof 和 ferror 鉴别出现者为何。 定义 ： 从给定输入流 stream 读取至多 count 个对象到数组 buffer 中，如同以对每个对象调用 size 次 fgetc ，并按顺序存储结果到转译为 unsigned char 数组的 buffer 中的相继位置。流的文件位置指示器前进读取的字符数。 若出现错误，则流的文件位置指示器的结果值不确定。若读入部分的元素，则元素值不确定。 直白点说就是从 一个流（文件）种读取 count 个 size 大小 的对象到 buffer 数组中 成功返回读取的对象数( &#x3C;= count), 失败返回 0 fwrite size_t fwrite( const void *restrict buffer, size_t size, size_t count, FILE *restrict stream ); 定义于头文件 &#x3C;stdio.h> 参数 ： buffer - 指向数组中要被写入的首个对象的指针 size - 每个对象的大小 count - 要被写入的对象数 stream - 指向输出流的指针 返回值 ： 成功写入的对象数，若错误发生则可能小于 count 。 若 size 或 count 为零，则 fwrite 返回零并不进行其他行动。 定义 写 count 个来自给定数组 buffer 的对象到输出流stream。如同转译每个对象为 unsigned char 数组，并对每个对象调用 size 次 fputc 以将那些 unsigned char 按顺序写入 stream 一般写入。文件位置指示器前进写入的字节数。 程序演示 现在我们想将学生的信息以二进制文本写入到 student.data 文件中 应该如何写这个程序呢？ 获取程序演示以及程序的详细注释！ ftell long ftell( FILE *stream ); 定义于头文件 &#x3C;stdio.h> 参数 ：stream - 要检验的文件流 返回值 ： 成功时为文件位置指示器，若失败发生则为 -1L 。 失败时，设 errno 对象为实现定义的正值。 定义 ： 返回流 stream 的文件位置指示器。 若流以二进制模式打开，则由此函数获得的值是从文件开始的字节数。 若流以文本模式打开，则由此函数返回的值未指定，且仅若作为 fs"},{"title":"指针和数组笔试题","slug":"c-notes/05-pointer-quiz","permalink":"/kb/posts/c-notes/05-pointer-quiz","category":"c-notes","description":"C语言指针与数组相关的笔试题及详细解析","date":"2026-06-16T00:00:00.000Z","content":"6.指针和数组笔试题 环境： 32 位机器 第一组 答案： 第二组 答案： 第三组 答案： 第四组 答案： 指针为什么也可以用 [] 运算符？ 对于指针 int* p = \"abc\"; p[1] 等价于 *(p + 1) 这是因为数组很多时候可以隐式转换成指针。 重点注意： printf(\"%d\\n\", strlen(&#x26;p)); &#x26;p 的类型是 char** ,但是C语言会将其隐式类型转换成 char* ，但是 strlen 访问的是地址p的内存空间，那这其实是未定义行为。 第五组 答案： 重点注意： printf(\"%d\\n\",sizeof(a[0]+1)) printf(\"%d\\n\",sizeof(&#x26;a[0]+1)) a[0] 与 &#x26;a[0] 的差异比较： printf(\"%d\\n\",sizeof(*(&#x26;a[0]+1))); 我们来一步一步分析： a[0] -> int[4] ; &#x26;a[0] -> int (\\*)[4] ; &#x26;a[0] + 1 -> int (\\*)[4] ; *(&#x26;a[0] + 1) -> int[4] printf(\"%d\\n\",sizeof(a[3])) sizeof 是一个运算符，并不是函数。它在预编译时期替换。而我们说的“数组下标访问越界”前提条件是 内存 访问越界，这个时期是程序运行时。a[3] 就是 int[4] 类型，所以就是 16。哪怕你写 a[100]都可以。 printf(\"%d\\n\", 16) 是程序运行时执行的语句。 关于 const 对于第一种写法，*p 是不能改变的；对于第三种写法，地址 p 是不能被改变的。 7. 指针笔试题 Ⅰ a + 1 ：a 隐式转换成 指针，指向 首地址后移 4 个字节。（a 隐式转换后是 int* 类型，它指向的 int 大小是 4 个字节，所以后移 4 个字节） &#x26;a 的类型是 int(*)[5] ,所以 &#x26;a + 1 后移 int[5] 的长度 所以最后输出的是：2，5 Ⅱ p + 0x1 p 加十六进制的 1，p 所指向的结构体大小是 20，所以 p 会增加 20 。但是注意 %p 输出的是 16 进制的地址，所以输出的是 0x100014 (unsigned long)p + 0x1 p 被强转成了一个数，所以输出的就是 0x100001 (unsigned int*)p + 0x1 p 被强转成了一个 int* 类型的指针，所以输出的是 0x100004 Ⅲ ptr1[-1] : 前面我们说过，这个操作相当于 *(ptr1 - 1) (int)a + 1 是将 a 先强转为 int 然后再加 1，所以 a 仅仅增加了 1 个字节 Ⅳ p[0] -> a[0] [0] ，所以输出的是 0 吗？ 并不是，注意看 a[3] [2]大括号内的内容，里面是圆括号而不是大括号，这是 逗号表达式 。 所以，a[0] [0] == 1 Ⅴ 指针（同类型）相减的意义是 两个指针之间间隔的元素个数 &#x26;p[4][2] -> 数组中的第 19 个元素（4 * 4 + 3） &#x26;a[4][2] -> 数组中的第 23 个元素 (4 * 5 + 3) 答案：FFFFFFFC,-4 Ⅵ &#x26;aa 的类型是 int(*)[2][5] ，所以 &#x26;aa + 1 指向的是整个数组后面的内存 。所以 *(ptr1 - 1) 的值是 10 aa aa + 1 让 aa 隐式转换为 int(*)[5] ,所以 aa + 1 指向的是元素 6 所在的地址。所以 *(ptr2 - 1) 的值是 5 Ⅶ Ⅷ 单目运算符从右向左依次运算。"},{"title":"if-else选择语句详解","slug":"c-notes/06-if-else-explained","permalink":"/kb/posts/c-notes/06-if-else-explained","category":"c-notes","description":"if-else语句的匹配规则、常见陷阱与代码风格建议","date":"2026-06-16T00:00:00.000Z","content":"关于 if else 选择结构 的两种写法： 上面两种写法有区别吗？ 直接看程序吧： 多个if 直接并列 输出： 多个else if 并列 输出： 总结 多个if 并列 程序会遍历所有的 if 条件。最后一个 else 与最后一个 if 配对，两者必有一个为真 多个 else if 并列 程序只要找到一个 真，就会退出整个 “条件体”。最后一个else 与 前面的任意一个语句 必有一个为真。 关于else： 第一种：else 与 最后一个 if 形成对立 第二种：else 与 除 else 外的整体形成对立"},{"title":"C语言常用字符串函数","slug":"c-notes/07-string-functions","permalink":"/kb/posts/c-notes/07-string-functions","category":"c-notes","description":"putchar、getchar、strlen、strcmp、strcpy等字符串函数详解","date":"2026-06-16T00:00:00.000Z","content":"字符串函数 指的是头文件 stdio.h 中的输入输出函数 和 头文件 string.h 里定义的我们平时直接使用的函数。 一下是本节重点讲解的 10 个函数。对于生僻点的字符串函数我们以后再讲。 putchar &#x26; getchar strlen &#x26; strnlen_s strcmp &#x26; strncmp strcpy &#x26; strncpy strcat &#x26; strncat 这些函数我们到处在用，可你有没有想过，究竟这些函数是怎么声明和定义的？他们远没有你想的那么简单。 以下被划掉的部分如果你理解，那是最好。不理解不可以不用纠结， 慢慢来 （一）putchar &#x26; getchar putchar int putchar( int ch ) 头文件 ：stdio.h 定义 ：写字符 ch 到 stdout 。在内部，字符于写入前被转换到 unsigned char 。 stdout：标准输出 我们后面会单独讲 意思就是：向标准输出写入一个字符 等价于 putc(ch, stdout) 。 参数 ： ch 要被写入的字符串 返回值 ： 成功时返回写入的字符。 失败时返回 EOF 并设置 stdout 上的错误指示器 EOF（end of file）是一个宏，值为 -1 第一次看到这个函数的 返回类型 和 参数类型 我其实很懵： 嗯？ 我输入的不是 char 类型的吗？ 怎么参数类型是 int ? 我看到的不是 char 类型的 A 吗？怎么返回类型是 int？ 其实，输出是什么不代表返回就是什么。scanf还返回整数呢，照样可以输出汉字。 下面的程序帮助大家理解： 输出： 上面我说慢慢来的时候也许有同学不屑：“这还用慢慢来？早会了！” 那好吧， putchar 的上面的定义中说它等价于 putc 要不我们再来看看 putc 是怎么定义的？与 putc 类似的还有个 fputc 要想真正理解它们还得看看 ferror ，一个个来呗？ A watched pot never boils —— 心急吃不了热豆腐 getchar int getchar(void) 头文件 ：stdio.h 定义 ： 从 stdin 读取下一个字符。 等价于 getc(stdin) 。 也就是 从标准输入读入一个字符 参数 ：无 返回值 ： 成功时为获得的字符 失败时为 EOF 。 getchar的返回值有什么用？ 如何退出下面程序中的 while循环？ 可以自己打出来先测试一下。 后面还会继续详细讲解 这部分知识。可以自行思考一下，也可以查阅资料看看。 我做了一个便于理解的图示，如果现在就想看，在公众号回复[0206]查看。 为了减少冗余，下面的程序我只写 main 函数部分， 但是在你写程序到时候你要记得引用头文件 string.h （二）strlen &#x26; strnlen_s 帮你理解： strlen： string lenth strlen size_t strlen( const char *str ) 头文件 ： string.h 参数 ：str - 指向要检测的空终止字符串的指针 返回值 ： 空终止字节字符串 str 的长度。 定义 ：返回给定空终止字符串的长度，即首元素为 str 所指，且不包含首个空字符的字符数组中的字符数。 若 str 不是指向空终止字节字符串的指针则行为未定义。 什么是 空终止字节字符串？ 空终止字节字符串（ NTBS ）是尾随零值字节（空终止字符）的非零字节序列。字节字符串中的每个字节都是一些字符集的编码。例如，字符数组 {'\\x63','\\x61','\\x74','\\0'} 是一个以 ASCII 编码表示字符串 \"cat\" 的 NTBS 。 strnlen_s size_t strnlen_s( const char *str, size_t strsz ) 头文件 ： string.h 参数 ： str - 指向要检测的空终止字符串的指针 strsz - 要检测的最大字符数量 返回值 ： 成功时为空终止字节字符串 str 的长度，若 str 是空指针则为零，若找不到空字符则为 strsz 。 定义 ： 除了若 str 为空指针则返回零，而若在 str 的首 strsz 个字节找不到空字符则返回 strsz 。 若 str 指向缺少空字符的字符数组且该字符数组的大小 &#x3C; strsz 则行为未定义； 换言之， strsz 的错误值不会暴露行将来临的缓冲区溢出。 strlen 与 strnlen_s 的区别与用法 1.空指针 2.没有终止符的字符串数组当作函数参数 猜一猜会输出什么？ 当我们不清楚字符串中有没有 '\\0' 时，我们要小心使用 strlen strlen 只有遇到 '\\0' 才会停止，这造成的潜在的数组越界风险。 3. 当 strsz > str的大小 时 1）若 str 有终止符 若 str 无终止符， 行为未定义 最后，对于 strnlen_s 来说 如果 strsz &#x3C; str数组大小 ，直接返回 strsz strlen 详解 const的作用 size_t strlen( const char *str ) const 的作用是什么？ 简单来说，如果你不希望这个函数改变你传入的数组，const 具有保护作用，使得 strlen 函数内部无法改变 str 数组每个元素的值。 const详解可以参考这篇文章： 点击查看 mystrlen mystrlen 的写法有很多，如果你的编译器是 VS，你甚至可以直接看编译器是如何去写的。 一下提供一种比较简洁的写法供大家参考： 不难（你细品 (三) strcmp &#x26; strncmp 如何记忆？ strcmp:string compare lhs:left-hand side rhs:right-hand side strcmp int strcmp( const char *lhs, const char *rhs ) 头文件 ： string.h 参数 ： lhs, rhs - 指向要比较的空终止字节字符串的指针 返回值 ： 若字典序中 lhs 先出现于 rhs 则为负值。 若 lhs 与 rhs 比较相等则为零。 若字典序中 lhs 后出现于 rhs 则为正值。 什么是字典序？ 简单理解就是在字母表中出现的顺序。 记法小窍门： lhs ASCII码值大 就为正 否则为负 解释：ASCII值大在字典序中肯定靠后，是后出现的 定义 ： 以字典序比较二个空终止字节字符串。 结果的符号是被比较的字符串中首对不同字符（都转译成 unsigned char ）的值间的差的符号。 若 lhs 或 rhs 不是指向空终止字节字符串的指针，则行为未定义。 strncmp int strncmp( const char *lhs, const char *rhs, size_t count ) 头文件 ： string.h 参数 ： lhs, rhs - 指向要比较的可能空终止的数组的指针 count - 要比较的最大字符数 返回值 ： 若字典序中 lhs 先出现于 rhs 则为负值。 若 lhs 与 rhs 比较相等，或若 count 为零，则为零。 若字典序中 lhs 后出现于 rhs 则为正值。 定义 ： 比较二个可能空终止的数组的至多 count 个字符。按字典序进行比较。不比较后随空字符的字符。 结果的符号是被比较的数组中首对字符（都转译成 unsigned char ）的值间的差的符号。 若出现越过 lhs 或 rhs 结尾的访问，则行为未定义。若 lhs 或 rhs 为空指针，则行为未定义。 strcmp 与 strncmp 比较 1. lhs 或 rhs 为非空终止字符字符串 2. count 的作用 3. \"Hello\" 与 \"Hello \" 的区别？ 字符串 \"Hello\" 是小于字符串 \"Hello \" 的。（用strcmp函数检测） 因为最后一次字符比较是 '\\0' 与 ' '比较， '\\0' ASCII码值为 0， ' ' ASCII码值为 32 如图： 输出： mystrcmp 先想后做，事半功倍： 按字符比较，都相等返回0； 出现不相等，返回 *lhs - *rhs 的差值 *lhs 或 *rhs 遇到 '\\0' 退出循环返回 差值 上面的 mystrcmp 看着很笨，当然是可以改进的。 自己思考一下。 答案放在了我的GitHub上。 点击查看 对你有帮助，麻烦给我点个小星星哦，方便下次查看。 如果你有更好的解法，欢迎 pull request ！ (四) strcpy &#x26; strncpy 帮助理解： strcpy:string copy dest:destination src:source strcpy char *strcpy( char *dest, const char *src ) 头文件 ： string.h 参数 ： dest - 指向要写入的字符数组的指针 src - 指向要复制的空终止字节字符串的指针 返回值 ： 返回 dest 的副本 定义 ： 复制 src 所指向的空终止字节字符串，包含空终止符，到首元素为 dest 所指的字符数组。 若 dest 数组长度不足则行为未定义。 若字符串覆盖则行为未定义。 若 dest 不是指向字符数组的指针或 src 不是指向空终止字节字符串的指针则行为未定义。 strncpy char *strncpy( char *dest, const char *src, size_t count ) 头文件 ： string.h 参数 ： dest - 指向要复制到的字符数组的指针 src - 指向复制来源的字符数组的指针 count - 要复制的最大字符数 返回值 ： 返回 dest 的副本 定义 ： 复制 src 所指向的字符数组的至多 count 个字符（包含空终止字符，但不包含后随空字符的任何字符）到 dest 所指向的字符数组。 若在完全复制整个 src 数组前抵达 count ，则结果的字符数组不是空终止的。 若在复制来自 src 的空终止字符后未抵达 count ，则写入额外的空字符到 dest ，直至写入总共 count 个字符。 若字符数组重叠， 若 dest 或 src 不是指向字符数组的指针（包含若 dest 或 src 为空指针）， 若 dest 所指向的数组大小小于 count ， 或若 src 所指向的数组大小小于 count 且它不含空字符， 则行为未定义。 strcpy 与 strncpy 的未定义行为 1. dest 和 src 一定不能是空终止字节字符串， 且要指向字符串 2. dest 与 src 覆盖 从 C99起 strcpy函数原型变成了这样： char *strcpy( char *restrict dest, const char *restrict src ) restrict 表示两个字符串是不重叠的 重叠并不是重复一样的意思。这一点我们目前不去深入。 3. dest 长度小于 src 这样写是可以通过编译的，但是你要知道这样做实际上已经越界了。 如果用数组的形式定义字符串，编译器才会报错。 可以看出，在这种情况下。编译器对数组更为敏感，数组的写法也更加安全。 4. strncpy：dest 大小小于 count 这点其实 3 也说明了。 对于 strcpy 来说， dest 的大小不能小于 src 而 strncpy 只需要 dest 的大小不小于 count 即可 5. src 大小小于 count 且 src 不含空字符 输出： 其实这也不难理解，strncpy 需要 '\\0' 来判断 src 是否写完。 如果有 src 结尾有'\\0' ，这时如果 count 还没有写满 函数会向 dest 中写入 '\\0' 直到写"},{"title":"数组赋值与const指针","slug":"c-notes/08-array-assignment","permalink":"/kb/posts/c-notes/08-array-assignment","category":"c-notes","description":"数组名的本质、const指针与数组赋值的关系","date":"2026-06-16T00:00:00.000Z","content":"指针 关于const 关于const 数组变量 是 const 的指针 在初学数组时，我们都有这样的思考：既然变量可以互相赋值，那么 数组 可以相互赋值吗？ 比如说： 一但这么些程序就会报错，为什么会这样呢？ 这是因为，以上面的为例： int arr2[3] = {0} 在编译器看来其实是这样的： int* const arr2 上一篇我们也学到了，const在 * 后 const 修饰的是地址 arr2，因此arr2是不能被改变的 int* const arr 与 int arr[] 是否可以划等号？ 我们先来看下面这个程序： 这个程序里，arr 的值与 q 的值相同我们应该是提前都会想到的。问题就是 这个 arr 的地址 与 q 的地址问题。他们会相同吗？虽然他们都指向 arr，但是这是两个不同的指针变量，所以他们的地址肯定是不会相同的。请看在我的机器上输出结果： &#x26;arr 竟然与 arr 与 q 是一样的！ 为什么会 这样？ &#x26;arr 与 arr 有什么区别？请看下面的程序： &#x26;arr + 1 和 arr + 1 差了 12 个字节， 刚好是一整个arr数组的长度。这意味着什么？ 取数组的地址 实际上 取走的是 ==整个数组==的 地址，它将整个数组视为整体，对它进行加减，大小是整个数组的大小 而 &#x26;q + 1 得值仅仅变化了 4 个字节 ，就是一个指针的大小 &#x3C;关于const 在程序中的使用教学 后续会在本编中加上 ，敬请期待 !>"},{"title":"枚举、结构体与联合","slug":"c-notes/09-enum-struct-union","permalink":"/kb/posts/c-notes/09-enum-struct-union","category":"c-notes","description":"枚举类型、结构体定义与内存对齐、联合体的使用","date":"2026-06-16T00:00:00.000Z","content":"枚举 Enum 枚举: 关键字： enum (enumeration) 用法： enum 枚举类型名 {名字 0， 名字 1 ...., 名字 n}； 注意： 枚举类型名通常不使用，用的是大括号内的名字，它们就是常量符号， 类型是 int ， 值依次从 0 到 n 如： 注意 ： 大括号内每个常量符号以 逗号 间隔 大括号后有 分号 用法示例 ： enum color 作为变量类型应该写完整 变量 t 虽然是 enum color 类型，但可以当作整型来进行内部计算和外部输入输出。 自动计数的枚举 原理 ：像上面这样，在 枚举 的所有元素后增加一个 Num&#x3C;内容> 表示 枚举 元素的总数 比如上例： red 值为 0 blue 值为 2 那么 NumColors 值为 3 NumColors 其实 表示的就是这个枚举的总个数 好处: 定义数组大小 和 遍历数组的时候就很方便 示例： 枚举量 声明枚举量的时候可以指定值 如： enum COLOR {RED = 1，YELLOW , GREEN = 5 }； 例如： 输出： 枚举是不是 int 类型？ 如果我们让编译器将 int 类型的 0 赋给 enum color 类型的 t 编译器不但不会报错，而且连 warning 都没有 但是我们知道 enum color 其实是一个我们定义出来的新的类型 可能现在你对此理解不深刻，等你学了 C++/Java 知道了 类与对象，你就明白了 因此，我们总结出： 枚举的特点 枚举 的类型很少使用 某种情况下，用 枚举 比用 很多 const int 方便 枚举 比 宏 好，因为它有 int 类型 结构 Struct 写在前面 ： 想理解结构体，不去多看代码，不去自己写是不可能的。 结构和数组，函数，指针混在一起就导致代码可能很长很乱。这没办法，一定要熬过去，要知道这才是 C语言的精髓 ，也是C++的基础。这里我们用的代码还不是很难。希望大家一定要攻克这里。实在想不通了可以后台私信我或者加入QQ群询问。 我写这篇文章看多了这些代码也有点头晕，为了小伙伴们，我也是拼了。 加油。 认识结构体 声明与使用 声明的三种方式 新手法 狠人法 常用方法 从 数组 看 结构体 结构体的初始化 输出: 可以看到，有两种初始化方法： 当你要初始化 struct 内的所有成员时: 1. struct date today = { 2020, 2, 9 }; 当你只想给 struct 内的某几个成员赋值时: 2. struct date tomorrow = { .month = 2, .day = 9 }; 没有被赋值的成员被初始化为 0 (类似数组) 结构运算 输出: p1 = (struct date){2020, 2, 9} 等价于 p1.year = 2020,p1.month = 2,p1.day = 9; p1 = p2 等价于 p1.year = p2.year .... 数组是无法无法做这两种运算的 指针: 结构体需要取地址 数组名本身就是地址,所以数组可以不用 &#x26; 符号 结构体作为函数参数 明天的日期 写一个程序:输入今天的日期,求明天的日期 看着这个问题简单,实际上稍微有点技巧. 给出下面四个特殊日期,大家可以思考一下: 2020 - 1 -31 2020 - 11 - 31 2020 - 12 - 31 2000 - 2 - 28 目测下面这个代码会被我这种喜欢空行的选手写的将近100行, 放在文中会影响阅读, 辛苦一下大家,**公众号后台回复 [0209] **获取代码 附:我觉得这个代码不难,不会的加Q群问吧,Q群关注我的公众号即可看到 结构指针作为参数 K &#x26; R 说过 （p.131） \"if a large structure is to be passed to a function,in is generally more efficent to pass a pointer than to copy the whole structure\" 大的结构体指针传参更为高效 指针所指的结构变量的成员访问 通常我们用第二种方法，即： p->month 读作 p 所指的 month （英文读作 arrow 箭头） 来实操一下吧，请听题： 问题描述： 我们 输入整型，浮点型可以直接用 scanf(\"%d\") 或者 scanf(\"%lf\") 那么我们可以直接像这样输入一个结构体吗？ 答案是我们需要自己写一个函数。 下面程序提供一个思路，可以自己改善/创造你的函数 结构数组 初始化方法 问题描述： 用一个结构数组记录 5 组时间，请求出这组时间下一秒的时间。 和上面年的问题一样，需要考虑进制问题。请思考一下，尝试自己写出。 嵌套的结构 嵌套的结构引入 嵌套的结构的成员访问 结构中有结构的数组 测试一下： 1.有下列代码段，则输出结果为： A: 0 B: 1 C: 2 D: 3 2.有如下变量定义，则对data中的a的正确引用是： A: (*p).data.a; B: (*p).a; C: p->data.a; D: p.data.a; 3.以下两行代码是否出现在一起？ A: √ B: × 公众号回复[0209 2]获取答案。 关于结构体的内存对齐等问题我们以后再说 联合 typedef 关键字 自定义类型 关键字 ： typedef 例如： 新的名字是某种类型的别名 改善了程序的可读性 请看下面这两段代码，你能分别出他们的意思是什么吗？ 第一个表示：一个没有名字的结构体类型 它有一个结构体变量 Date 第二个表示：将这个结构体类型重定义为 Date 如果你用你的编译器敲一下这段代码，会发现这两段代码的 Date 的颜色是不一样的 认识 联合 特点： 所有成员共享一个空间 同一时间只有一个成员是有效的 union的大小是其最大的成员 怎么去理解？请看下面程序，以32位机器为例 union 的常用场景 先看一下这段程序： 它的输出是： 这说明了chi的内存存储情况： 我们用计算机看一下 16 进制的 1234 是什么样子： chi的大小是 4个字节，我们可以表示 chi.i 为 00 00 04 D2 这种 高位（d2） 放在 低地址; 低位 （00）放在 高地址 的存储模式叫做 小端 我们现在用的 x86 的机器 基本都是这种存储方式。 我们可以利用 union 得到一个 int 或者 double 等等的内部的字节 这是一个有用的工具。当我们讲到文件时候，大家就对它的功能有更熟悉的理解了。"},{"title":"全局变量、宏与大程序结构","slug":"c-notes/10-globals-macros","permalink":"/kb/posts/c-notes/10-globals-macros","category":"c-notes","description":"全局变量、static变量、宏定义#define与多文件编程","date":"2026-06-16T00:00:00.000Z","content":"全局变量 认识 全局变量 定义在函数外的变量就是全局变量 全局变量具有全局的生存期和作用域 它们与任何函数无关 任何函数（定义在全局变量后的的函数）内部都可以使用它们 例如： 输出： 全局变量的初始化 没有初始化的全局变量 默认值为 0 指针默认为 NULL 只能用 编译时刻已知 [^1]的值来初始化全局变量 全局变量的初始化发生在main函数之前 注释1： 下面这段代码在某些编译器（dev c++）上是可以编译的，但是在 vs 上是不能编译的 但是，这种方式是不推荐的 被隐藏的全局变量 如果函数内部存在与全局变量同名的变量，则全局变量被隐藏。 输出： 即使 gAll 在 main 函数中被覆盖，f 函数中的 gAll 也是不会被该改变的 为什么会这样？自己思考一下。 静态本地变量 在本地变量定义时加上 static 修饰符就成为静态本地变量 当离开函数的生存期后，静态本地变量会继续存在并保持其值 静态本地变量的初始化只会在第一次进入这个函数时进行，以后进入函数时会保持上次离开时的值。 例： 不用static 的情况 输出： 使用static ： 输出： 看看地址 输出： 全局变量 gAll 与 静态局部变量 All 在内存中相邻 总结 静态本地变量实际上是特殊的全局变量 它们位于相同的内存区域 静态本地变量具有全局的生存期，函数内的局部作用域 返回指针的函数 请同学们先看一下下面这个程序： 输出： i 和 k 的内存其实是同一块空间 总结 返回 本地变量 的地址是危险的 返回 全局变量 或 静态局部变量 的地址是安全的 返回函数内 malloc 的内存是安全的，但是容易造成问题 最好的做法是 返回传入的指针 说了这么多，总结一句话 尽量避免使用 全局变量 和 静态本地变量 为什么这里就不深讲了，有兴趣的朋友可以下来自己查查。 编译预处理 与 宏 编译预处理指令 # 开头的是编译预处理指令 它们不是 C语言的一部分，但是 C语言离不开他们 #define 用来定义一个宏 define 关键字 回想我们刚学 double 的时候，是不是计算过圆的面积。当时我们可能是这样写的： 现在我们用 宏 就不需要用 const 修饰的全局变量了，我们也说过，全局变量最好不用。 现在，我们打开我们的虚拟机，进入 Linux 系统。 现在多出来了 4 个文件，蓝色的是文件夹，我们不去管它，绿色的是可执行文件，类似 windows 的 .exe 文件 现在我们主要关注这 3 个中间文件 一个 c文件编译的过程文件变化是这样的： .c （处理编译预处理指令）-> .i （产生汇编代码）-> .s （汇编生成目标文件） -> .o （链接等） -> a.out 可以看到 .i 文件时很大 我们发现程序中的宏 PI 被换成了它所表示的 数字 这种替换是 简单的文本替换 ，我们再试试其他的替换方式： 我们再试试这样，定义宏的时候 不带双引号： 因此可知， 被 \" \" 扩起来的字符串 宏 是不会替换的 总结 格式： #define &#x3C;名字> &#x3C;值> 注意结尾没有分号，因为不是 C 的语句 名字必须是一个单词，值可以是任何（注意字符串替换定义时需要带引号） 在 C语言的编译器开始编译之前 ，编译预处理程序（cpp）会把程序中的宏的名字替换为值 linux/unix 编译并保留中间文件指令： gcc --save-temps 查看文件结尾： tail 宏 如果在一个宏的值中有其他宏的名字，这些宏也是会被替换的 如果一个宏的值超过一行，最后一行之前的行末需要加 \\ 宏的值后面出现的注释不会被当作宏的值的一部分 没有值的宏 #define _DEBUG #define _CRT_SECURE_NO_WARNINGS 用 VS 的应该都知道这个吧，加上这个你就可以直接用 scanf 而不是 scanf_s 了 这类宏是用来做条件编译的，后面有其他编译预处理指令来检查这个宏是否已经被定义过了。 比如有这个宏执行这部分代码，没有则执行另外一部分 预定义的宏 __LINE__ __FILE__ __DATE__ __TIME__ __STDC__ 我们来试着用一下： 输出： 值得注意的是， __LINE__ 表示的是它自己所在的行数 你们在熟睡，而我还在给你们写教学，关注我/点个赞/转发 不过分吧~ 带参数的宏 #define cube(x) ( (x) * (x) * (x) ) 例如： 输出： 容易犯的错误 一下这两种写法在程序中会不会有问题？ #define ERROR(1x) (x * 57) #define ERROR2(x) (x) * 57 思考一下这个程序会的到你想要的结果吗？ 输出： 为什么会这样呢？我们不妨来看一下， .i 文件内部： 定义带参数的宏的原则 一切都要有括号 整个值有括号 每个参数都有括号 所以，上面错误的例子的正确的写法就是： #define ERROR ( (x) * 57 ) 带参数的宏的更多用法 ： #define MIN(a, b) ((a) > (b) ? (b) : (a)) 定义宏切记不要加分号 错误示范： VS 会报错 ：没有匹配 if 的非法 else，为什么呢？ 因为如果你在宏后面加了 ; ,你又在 if 内的语句后加了 ; 这样在 .i 的阶段，if 后的语句有了两个 ; ,即： PRETTY_PRINT(\"less than 10\\n\");; 第二个 ; 表示 一个空语句，这样 else 前面就没有对象可以匹配了 总结 #开头的预处理指令并不是 C语言独有的内容 宏的参数时没有类型的 大型程序中宏的使用很常见 宏可以很复杂，可以产生函数 使用运算符 # 和 ## 部分宏会被 inline 函数取代 中西方差异（国人少用） Quiz： 请看下面的代码片段，判断这段程序会输出什么？ A: B B: C C: D D: E 这道题是需要都脑子的呦! 公众号后台回复： 0211 1 查看答案和解析 大程序结构 多个源代码文件 多个源文件 .c 引入 回想我们学习的过程，开始是 main（）里的代码太长了，我们学习了函数，将其分开 现在如果 一个源文件太长了，我们就可以将其分成几个源文件 怎么让多个源文件联系起来？ 在编译器上创建一个项目，将你想操作的 .c 文件放到同一个项目中 头文件 .h \" \" 还是 &#x3C; > ? #include 有两种形式来指出要插入的文件 \" \" 要求编译器首先在当前目录（.c 文件所在目录）寻找这个文件；如果没有，再去编译器指定的目录寻找。 自己的头文件用 &#x3C; > 让编译器只在指定位置寻找 。系统的头文件用 编译器知道自己的标准库的头文件在哪里 环境变量 和 编译器命令行参数也可以指定寻找头文件的目录 #include 的误区 #include 不是用来引入库的 stdio.h 中只有函数的声明，函数的定义在其他的地方 C语言编译器默认会引入所有标准库 #include&#x3C;stdio.h> 的作用其实就是将 这个头文件的所有内容 插入到这个文件中来。目的是让编译器知道你使用的函数时所给的参数是否正确。（类似函数的声明） 为什么不引用 stdlib.h 依然可以使用 malloc ? 这时因为在你调用函数前没有声明函数（引入头文件），编译器回去猜测 参数 和 函数返回类型都为 int 型 恰好 malloc 的参数 size_t 是 long int ，返回值是个指针，也可以看作是 16进制的 整型。 为什么？可以参考我的另一篇文章，点击跳转 头文件 使用和定义函数的地方都应该包含这个头文件 将 函数声明 全局变量 放入 .h 文件 不对外公开的 函数&#x26;变量 函数&#x26;全局变量前加上 static 就使得这个 函数/变量 只能在当前文件中被使用 声明 extern 当一个c 文件想调用另一个 c文件中定义的全局变量时 需要在头文件中加上 extern &#x3C;类型> &#x3C;变量名> 来声明这个变量 例如： 声明不产生代码 避免重复声明 请看下例： 如何避免上述这种重定义情况？ 条件编译和宏 运用条件编译和宏，保证这个头文件在一个编译单元中只会被 include 一次 #pragma once 也能起到相同作用，但不是所有的编译器都支持 这就是我们前面说的预定义的宏的一种使用方法。 应用这种方法我们再看上例"},{"title":"素数判断方法详解","slug":"c-notes/11-prime-detection","permalink":"/kb/posts/c-notes/11-prime-detection","category":"c-notes","description":"多种素数判断方法：从朴素到优化的算法演进","date":"2026-06-16T00:00:00.000Z","content":"我们要判断素数，首先要知道素数的定义。 素数：质数又称素数。一个大于1的自然数，除了1和它自身外，不能被其他自然数整除的数叫做质数;否则称为合数。 知道了素数的定义，那么我们应该想一下，如何去判断一个数是否为素数？ 一种思路是，我们在每次得到一个数后，都去计算，去尝试因式分解它，看它除了1和自身之外还有没有其他因子 另一种是，我们去查阅素数表，看这个数在不在素数表上。那我们就要先得到素数表。 以下除了第一种方法，第2~4种方法都是用第二种思路做的 当要判断的目标数很少时，第一种高效。但是当给定的目标数组很多，数也很大时。后面的思路配上高效的查找算法，显然更高效 方法1：暴力求解 1-1:稍微动动脑 思想 ： 根据素数的定义思考。素数是大于1的自然数，除了1和自身外，其他数都不是它的因子。 那我们就可以用一个循环，从2开始遍历到这个数减去1，如果这个数都不能被整除，那么这个数就是素数。 也就是说： 给定一个数 n , i 从 2 开始取值，直到 n - 1(取整数),如果 n % i != 0 , n 就是素数 进一步思考，有必要遍历到 n - 1 吗？ 除了1以外，任何合数最小的因子就是2，那最大的因子就是 n/2 那我们就遍历到 n/2就足够了 这样我们就可以写出这个算法的核心代码： 1-2：再进一步 思想 ： 在上面的基础上，其实不需要遍历到 n/2，只需要到 根号n（包含根号n） 就可以了。为什么呢？这是个数学问题，大家自行思考一下。 核心代码： 从第二种方法开始，我们都是先完成判断素数数组，然后用二分法去查找判断数组 这里说一下以下三种方法牵扯的概念： 范围：1 ~ 范围上限N 范围上限N：判断素数需要用户输入随机素数，这个随机素数的范围是1 ~ N 判断素数数组：将数组的 下标 与 1 ~ N 的自然数一一对应起来。 判断 1到N 的自然数是否为素数，其实就是判断数组的下标是否为素数，如果是 给这个下标所对应的判断素数数组元素赋1，否则赋0 比如：我要判断3是否为素数，我们就找到 判断素数数组isPrime 中的下标为3的元素，即： isPrime[3] 如果 3 是素数 ， 赋值1，即 isPrime[3] = 1 如果 3 不是素数，赋值0 ，即isPrime[3] = 0 这样我们在用二分法查找时，查找数组下标就可以，找到下标后返回下标对应的判断素数数组的值。 如果是1说明下标对应的自然数是素数，否则不是 方法2：用素数表来判断素数 思路 ： 如果一个数不能整除比它小的任何素数，那么这个数就是素数 这种“打印”素数表的方法效率很低，不推荐使用，可以学习思想 核心代码： 方法3：普通筛法——埃拉托斯特尼(Eratosthenes)筛法 思路 : \\1. 我们的想法是，创建一个比范围上限大1的数组，我们只关注下标为 1 ~ N（要求的上限） 的数组元素与数组下标（一一对应）。 \\2. 将数组初始化为1。然后用for循环，遍历范围为：【2 ~ sqrt(N)】。如果数组元素为1，则说明这个数组元素的下标所对应的数是素数。 \\3. 随后我们将这个下标（除1以外）的整数倍所对应的数组元素全部置为0，也就是判断其为非素数。 这样，我们就知道了范围内（1 ~ 范围上限N）所有数是素数（下标对应的数组元素值为1）或不是素数（下标对应的数组元素值为0） 用百度百科对埃拉托斯特尼筛法简单描述： 要得到自然数n以内的全部素数，必须把不大于 的所有素数的倍数剔除，剩下的就是素数。 核心代码： 方法4：线性筛法——欧拉筛法 思路 : 我们再思考一下上面的埃拉托斯特尼筛法，会发现，在“剔除“非素数时，有些合数会重复赋值。这样就会增加复杂度，降低效率。 比如：范围上限N = 16时 6，12是重复的。如何减少重复呢？ 核心代码： 如果你没有理解，可以参考下例 [以上四种算法的完整代码在我的github上，帮助到你了不妨给我点个star哦~]( https://github.com/hairrrrr/win.ccode/tree/master/Pactise/2020WinterVacation/Prime/Prime Judgement) 感谢指出我错误的微信网友： 大异小同 。 本次修改内容： \\1. 1-1中的代码，for循环的循环控制 i &#x3C; target / 2 改为 i &#x3C;= target 错误情况：当 target == 4 时，target / 2 的值是 2，i 从 2开始，如果 循环控制是：i &#x3C; target / 2, 则不会进入 for 循环，所以会将 4 误判为素数 \\2. sqrt 函数的返回值是 double 类型。 将 i &#x3C;= sqrt(target) 改为 i &#x3C;= (int)sqrt(target) sqrt 函数的函数原型：double sqrt(double arg); 2020 - 2 - 24 日修改："},{"title":"字符串入门","slug":"c-notes/12-string-tips","permalink":"/kb/posts/c-notes/12-string-tips","category":"c-notes","description":"字符串的基础概念、存储方式与常见操作","date":"2026-06-16T00:00:00.000Z","content":"字符串入门 字符串基础： 基本概念： 以 0 结尾的一串字符 0 和 '\\0' 是一样的，但是与 ’0‘ 不同 0标志着字符串的结束，但它不是字符串的一部分 计算字符串长度不包括这个0 字符串以数组的形式存在，以数组或指针的形式访问（更多以指针形式） 头文件 string.h 表示方法 字符串常量 形如\"Hello\"这样 被双引号引起来的字符串 就叫字符串常量（字面量） 字符串常量 ：\"Hello\" 大小 6 加上结尾表示结束的0 C的编译器会将 两个连续的字符串连接起来 字符串常量通常放在 代码段 ，这个区域的数据通常是只读的 比如： 这样会输出 或者你也可以这样写： 这种写法在我的计算机上提示有语法错误，那位大佬能指导一下？（翁恺老师可以在这样写，难道是C99语法。。。） 注意 不能对字符串做运算 可通过数组方式遍历字符串 可以通过字符串常量来初始化字符串数组 字符串输入输出 读取方式 可以用以下程序测试： scanf 读入一个单词（遇到 空格 ， tab ， 回车 结束） scanf 不安全，因为不知道读入的内容的长度 安全的输入 scanf(\"%3s\",str1) % 与 s 之间的数字表示，最多允许读入的字符数量，这个数的最大值应该等于 数组大小 - 1 请看下面的程序： 分别输入以下两组值，会输出什么？ 1234 1234 123 123 注意 char buffer[100] = \"\" （\"\"是紧挨的，下同） 它的意思是 buffer[0] == '\\0' 表示空字符串 char buffer[] = \"\" 它表示字符串长度为1 我们不妨来测试一下： 输出 0 用%c 的格式输出为空格 字符串数组 — 指针数组的一类 这里我们重点介绍 字符串数组。什么是指针数组，可以参考我CSDN上的这篇文章： [C语言复习巩固]指针（入门） char* str1[5] 与char str2[5][10] 从上图可以看出，char* str1[5] 内存放的是 字符串的指针 ，指向了一个地址 而char str2[5][10]则是在栈中开辟了50char大小的空间来存放这些字符 下面的程序通过初始方式来帮助你理解两者的不同： 字符串数组的一个应用 写一个程序。输入一个数代表月份，输出这个月的英语单词。 我们可以用 if else 来做也可以用 switch 来做。但是今天学习了字符串数组，我们有了更简单的方式来完成。 请看下面的程序： 这样写代码被大大缩短了 main函数的参数？ 通常我们写 main 函数会这样写： int main() ，这样写其实并不严谨。 但这并不是大家的错，是谭老师没教好。 如果你的main函数没有参数，从今天起我们都这样写 int main(void) ，为什么呢？ 首先你的main函数和大多初学者不一样，可以装一装大佬。 其次，我们知道，main函数也是函数，是被其他函数调用的。这个在我函数的文章中讲解过。感兴趣的朋友可以看一下。 点击查看 这是mian函数有参数的形式（我们在这里简化了一下，方便理解） int main(int argc, char* argv[]) int argc 是字符串数组 char* argv[] 的大小 （也称“参数计数” ，是命令行参数的数量） int argv[] 也称“参数向量” 是指向命令行参数的指针数组 argv[0] 指向可执行程序的名称（.exe） argv[1] ~ argv[argc - 1] 指向实际参数，也就是命令行 argv[argc] 是空指针NULL 如果你不知道我上面写的是什么，那么就要注意啦！其他的都是开玩笑，这里才是这一节的重点。 一下内容面向小白，大佬可以跳过这一段 什么是命令行？ 看完这篇文章后，去搜索一下linux是什么以及linux对程序员有什么用相关问题。如果你有收获，分享一下这篇文章不过分吧 命令行说白了就是一个替代你鼠标的东西，它可以让你更加高效的去做很多事情。 比如在win10下你在键盘上按下 win + R 然后再跳出的窗口里输入 cmd 打开的小黑窗口其实就是命令行窗口。在这个窗口里你可以输入指令，来完成各种事情。比如基础的 dir 展示当前目录下内容 cd 进入文件夹 cd .. 退出文件夹等等。 为了更深入的了解mian函数的参数有什么用，我们在实际的程序中来感受一下。 用main函数的参数实现的 命令行中 两个整数的 四则运算 先看一下程序运行的效果： 关注公众号后台回复 【2020 0205】获得源码以及详细讲解"},{"title":"内存对齐与位段","slug":"c-notes/13-memory-alignment","permalink":"/kb/posts/c-notes/13-memory-alignment","category":"c-notes","description":"结构体内存对齐规则、位段的使用与内存优化","date":"2026-06-16T00:00:00.000Z","content":"零 前言 自定义类型也就是：结构体，联合和枚举。这部分的基础知识在前面的文章中我们也详细的讲过。 点击阅读 我们这一节主要来讲一相关的些比较重要的知识。 一 结构体 1. 内存对齐 Ⅰ）引入 上面是一个结构体，也是我们自定义的一种类型。我们知道，任何类型都有大小，那么结构体 S1 的大小是多少？ 是结构体各成员变量大小的和吗？如果是的话，那结构体 S1 的大小就是 6 那我们设计一个程序验证一下： 输出是：12 ，这个 12 是怎么得来的呢？ 想要知道这个问题答案，那我们就要了解一下 内存对齐 。 Ⅱ）为什么要内存对齐？ 内存对齐关系到 CPU 读取数据的效率 和 一些其他原因。我们这里不做展开，有兴趣可以自己查一下。 Ⅲ）规则 第一个成员在与结构体变量偏移量为0的地址处。 其他成员变量要对齐到某个数字（对齐数）的整数倍的地址处。 对齐数 = 编译器默认的一个对齐数 与 该成员大小的 较小值 。 VS中默认的值为8 结构体总大小为最大对齐数（每个成员变量都有一个对齐数）的整数倍。 如果嵌套了结构体的情况，嵌套的结构体对齐到自己的最大对齐数的整数倍处，结构体的整体大小就是所有最大对齐数（含嵌套结构体的对齐数）的整数倍。 四）练习 判断下面结构体的大小： VS 默认的对齐数是 8，32 位机器 1 解析： 1（char） (+ 3 (int 应该对齐到 4 的整数倍上，也就是 4，所以应该给 1 加上 3 凑成 4)) + 4（int） + 1（char） (+ 3 最后整个结构体大小为最大对齐数（也就是 4）的整数倍处，所以结构体的大小不是 9 而是 12 )（最大对齐数是最大成员的对齐数，这个是前面算过的（成员大小和默认对齐数取小）） 答案：12 2 第一个例题已经详细的分析了判断结构体大小的步骤，下面不再赘述。 1 (char) + 1 (char) (+ 2 ) + 4 (int) 答案：8 3 8 (double) + 1 (char) （+ 3 ) + 4 (int) 答案：16 4 例 3 中，我们已经知道了 S3 的大小是 16 1 (char) (+ 7 (结构体大小是 16 和 编译器默认对齐数 8 取较小值，所以结构体要对齐的整数倍是 8)) + 16 (S3) + 8 (double) 答案：32 不确定你可以自己在你的编译器上敲一下，看看运行结构，前提是编译器的默认对齐数是 8 ，如果不是，结果可能会不一样，那么编译器的默认对齐数可以修改吗？ 2. 修改默认对齐数 只需要加上一条指令即可： 如果你想取消设置的默认对齐数，还原为默认： 二 位段 1.了解位段 位段的声明和结构是类似的，有两个不同： 位段的成员必须是 int、unsigned int 或signed int 。 位段的成员名后边有一个冒号和一个数字。 存储方式： 位段的成员可以是 int unsigned int signed int 或者是 char （属于整形家族）类型 位段的空间上是按照需要以4个字节（ int ）或者1个字节（ char ）的方式来开辟的。 位段涉及很多不确定因素，位段是不跨平台的，注重可移植的程序应该避免使用位段 位段的应用： 可以自行了解 IP数据报格式 。"},{"title":"动态内存管理","slug":"c-notes/14-dynamic-memory","permalink":"/kb/posts/c-notes/14-dynamic-memory","category":"c-notes","description":"malloc、calloc、realloc、free的使用方法与常见错误","date":"2026-06-16T00:00:00.000Z","content":"想看更好的排版可以阅读原文 点击阅读原文 思维导图 目录 正文 零 简单了解内存区域划分 一 动态内存函数 1.1 malloc malloc -> memory allocate void* malloc (size_t size) size_t 类型就是 unsigned long 库函数： stdlib.h 解释： 分配 size 字节的未初始化内存。 若分配成功，则返回为任何拥有 基础对齐 的对象类型对齐的指针。 若 size 为零，则 malloc 的行为是实现定义的。例如可返回空指针。亦可返回非空指针；但不应当 解引用 这种指针，而且应将它传递给 free 以避免内存泄漏。 **参数：**size - 要分配的字节数 返回值： 成功时，返回指向新分配内存的指针。为避免内存泄漏，必须用 free() 或 realloc() 解分配返回的指针。 失败时，返回空指针。 英文文档： Allocate memory block Allocates a block of size bytes of memory, returning a pointer to the beginning of the block. The content of the newly allocated block of memory is not initialized, remaining with indeterminate values. If size is zero, the return value depends on the particular library implementation (it may or may not be a null pointer ), but the returned pointer shall not be dereferenced. Parameters size Size of the memory block, in bytes. size_t is an unsigned integral type. Return Value On success, a pointer to the memory block allocated by the function. The type of this pointer is always void* , which can be cast to the desired type of data pointer in order to be dereferenceable. If the function failed to allocate the requested block of memory, a null pointer is returned. 例1：malloc 1.2 free void free( void* ptr ) **头文件：**stdlib.h 解释： 解分配之前由 malloc() 、 calloc() 或 realloc() 分配的空间 若 ptr 为空指针，则函数不进行操作。 若 ptr 的值不等于之前从 malloc() 、 calloc() 、 realloc() 返回的值，则行为未定义。 若 ptr 所指代的内存区域已经被解分配，则行为未定义，即是说已经以 ptr 为参数调用 free() 或 realloc() ，而且没有后继的 malloc() 、 calloc() 或 realloc() 调用以 ptr 为结果。 若在 free() 返回后通过指针 ptr 访问内存，则行为未定义（除非另一个分配函数恰好返回等于 ptr 的值）。 参数： ptr - 指向要解分配的内存的指针 返回值： 无 **注意：**此函数接收空指针（并对其不处理）以减少特例的数量。不管分配成功与否，分配函数返回的指针都能传递给 free() 。 英文文档 Deallocate memory block A block of memory previously allocated by a call to malloc , calloc or realloc is deallocated, making it available again for further allocations. If ptr does not point to a block of memory allocated with the above functions, it causes undefined behavior . If ptr is a null pointer , the function does nothing. Notice that this function does not change the value of ptr itself, hence it still points to the same (now invalid) location. Paramaters ptr Pointer to a memory block previously allocated with malloc , calloc or realloc . Return Value none If ptr does not point to a memory block previously allocated with malloc , calloc or realloc , and is not a null pointer , it causes undefined behavior . 1.3 calloc void* calloc( size_t num, size_t size ) **头文件：**stdlib.h 解释： 为 num 个对象的数组分配内存，并初始化所有分配存储中的字节为零。 若分配成功，会返回指向分配内存块最低位（首位）字节的指针，它为任何类型适当地对齐。 若 size 为零，则行为是实现定义的（可返回空指针，或返回不可用于访问存储的非空指针）。 参数： num - 对象数目 size - 每个对象的大小 返回值： 成功时，返回指向新分配内存的指针。为避免内存泄漏，必须用 free() 或 realloc() 解分配返回的指针。 失败时，返回空指针。 注意： 因为对齐需求的缘故，分配的字节数不必等于 num*size 。 初始化所有位为零不保证浮点数或指针被各种初始化为 0.0 或空指针（尽管这在所有常见平台上为真）。 英文文档： Allocate and zero-initialize array Allocates a block of memory for an array of num elements, each of them size bytes long, and initializes all its bits to zero. The effective result is the allocation of a zero-initialized memory block of (num*size) bytes. If size is zero, the return value depends on the particular library implementation (it may or may not be a null pointer ), but the returned pointer shall not be dereferenced. Parameters num Number of elements to allocate. size Size of each element. size_t - is an unsigned integral type. Return Value On success, a pointer to the memory block allocated by the function. The type of this pointer is always void* , which can be cast to the desired type of data pointer in order to be dereferenceable. If the function failed to allocate the requested block of memory, a null pointer is returned. 例2：calloc 1.4 realloc 英文文档： Reallocate memory block Changes the size of the memory block pointed to by ptr. The function may move the memory block to a new location (whose address is returned by the function). The content of the memory block is preserved up to the lesser of the new and old sizes, even if the block is moved to a new location. If the new size is larger, the value of the newly allocated portion is indeterminate. In case that ptr is a null pointer, the function behaves like malloc , assigning a new block of size bytes and returning a pointer to its beginning. C90： Otherwise, if size is zero, the memory previously allocated at ptr is deallocated as if a call to free was made, and a null pointer is returned. C99/C11： If size is zero, the return value depends on the particular library implementation: it may either be a null pointer or some other location that shall not be dereferenced. If the function fails to allocate the requested block of memory, a null pointer is returned, and the memory block pointed to by argument ptr is not deallocated (it is still valid, and with its contents unchanged). Parameter ptr Pointer to a memory block previously allocated with malloc , calloc or realloc . Alternatively, this can be a null pointer , in which case a new block is allocated (as if malloc was called). size New size for the memory block, in bytes. s"},{"title":"字符串与内存操作函数","slug":"c-notes/15-string-and-memory-functions","permalink":"/kb/posts/c-notes/15-string-and-memory-functions","category":"c-notes","description":"strstr、strtok、memcpy、memmove等函数的使用与实现","date":"2026-06-16T00:00:00.000Z","content":"思维导图 目录 正文 strlen &#x26; strlen_s getchar() &#x26; putchar() strcmp &#x26; strncmp() strcpy() &#x26; strncpy() strcat &#x26; strncat() 以上这几个函数在我的另一篇文章中我已经详细讲过了。 文章链接 这篇文章中，我们先主要来简单实现一下这几个函数，然后再讨论其他函数。 序 老朋友们 myStrlen &#x26; myStrcat &#x26; myStrcpy MyStrcmp 始 新朋友们 1. strstr char *strstr( const char* str, const char* substr ); 定义于头文件：&#x3C;string.h> 查找 substr 所指的空终止字节字符串在 str 所指的空终止字节字符串中的首次出现。不比较空终止字符。 若 str 或 substr 不是指向空终止字节字符串的指针，则行为未定义。 参数: str - 指向要检验的空终止字节字符串的指针 substr - 指向要查找的空终止字节字符串的指针 返回值: 指向于 str 中找到的子串首字符的指针，或若找不到该子串则为 NULL 。若 substr 指向空字符串，则返回 str 。 想象实现 MyStrstr 是我们产品经理的需求，先来看看他的需求是什么： 查找 字符串 substr 在字符串 str 中首次出现的位置（返回找到的字串的首字符的指针） 思路 我们先在 str 中找到 substr 的第一个元素 比较 str 的下一个字符与 substr 的下一个字符是否相等（可以循环实现） 如果 substr 中有一个字符与 str 中的是不一样的，那么 substr 应该从首个元素开始重新在 str 中继续向下寻找，直到找到或者 str 结束 我忽略了一个条件：当 substr 重新从第一个元素开始在 str 中寻找时，str 应该重置为 上一次 str 所在位置的下一个字符处(比如 \"cacacat\" 中寻找 \"cacat\") 实现 2. strtok 了解这个函数即可。 char *strtok( char *str, const char *delim ) 定义于头文件 &#x3C; string.h > 参数： str - 指向要记号化的空终止字节字符串的指针 delim - 指向标识分隔符的空终止字节字符串的指针 返回值： 返回指向下个记号起始的指针，或若无更多记号则返回 NULL 。 注意： 此函数是破坏性的：它写入 ' \\0 ' 字符于字符串 str 的元素。特别是，字符串字面量不能用作 strtok 的首参数。 每次对 strtok 的调用都会修改静态对象：它不是线程安全的。 strtok 中有个 static 修饰的变量记录下来上次位置 3. memcpy void* memcpy( void *dest, const void *src, size_t count ); 定义于头文件 :&#x3C;string.h> 从 src 所指向的对象复制 count 个字符到 dest 所指向的对象。两个对象都被转译成 unsigned char 的数组。 若访问发生在 dest 数组结尾后则行为未定义。若对象重叠（这违背 restrict 契约） (C99 起)，则行为未定义。若 dest 或 src 为非法或空指针则行为未定义。 参数： dest - 指向要复制的对象的指针 src - 指向复制来源对象的指针 count - 复制的字节数 返回值： 返回 dest 的副本，本质为更底层操作的临时内存地址，在实际操作中不建议直接使用此地址，操作完成以后，真正有意义的地址是dest本身。 注意： memcpy 可用于设置分配函数所获得对象的 有效类型 。 memcpy 是最快的内存到内存复制子程序。它通常比必须扫描其所复制数据的 strcpy ，或必须预防以处理重叠输入的 memmove 更高效。 许多 C 编译器将适合的内存复制循环变换为 memcpy 调用。 在 严格别名使用 禁止检验同一内存为二个不同类型的值处，可用 memcpy 转换值。 注： void* 只包含地址，没有内存空间大小这样的信息，所以 void* 不能解引用，也不能进行运算 void* 是为了兼容各种类型的指针，算是一种简单的“泛型编程”。 简单的用法： 实现 思考 请看下例： memcpy 该表了 i 作为整型的内存布局，所以 i 可以直接用 %f 输出 4. memmove void* memmove( void* dest, const void* src, size_t count ) 定义于头文件 :&#x3C; string.h > 从 src 所指向的对象复制 count 个字节到 dest 所指向的对象。两个对象都被转译成 unsigned char 的数组。对象可以重叠：如同复制字符到临时数组，再从该数组到 dest 一般发生复制。 若出现 dest 数组末尾后的访问则行为未定义。若 dest 或 src 为非法或空指针则行为未定义。 参数： dest - 指向复制目的对象的指针 src - 指向复制来源对象的指针 count - 要复制的字节数 返回值： 返回 dest 的副本，本质为更底层操作的临时内存地址，在实际操作中不建议直接使用此地址，操作完成以后，真正有意义的地址是dest本身。 注意： memmove 可用于设置由分配函数获得的对象的 有效类型 。 尽管说明了“如同”使用临时缓冲区，此函数的实际实现不会带来二次复制或额外内存的开销。常用方法（ glibc 和 bsd libc ）是若目标在源之前开始，则从缓冲区开始正向复制，否则从末尾反向复制， 完全无重叠时回落到更高效的 memcpy 。 在 严格别名时用 禁止检验同一内存为二个不同类型的值时，可使用 memmove 转换值。 重叠的含义 实现 5. memcmp int memcmp( const void* lhs, const void* rhs, size_t count ); 参数: lhs, rhs - 指向要比较的对象的指针 count - 要检验的字节数 返回值: 若 lhs 以字典序出现前于 rhs 则为负值。 若 lhs 与 rhs 比较相等，或 count 为零则为零。 若 lhs 以字典序出现后于 rhs 则为正值。 注意: 此函数读取 对象表示 ，而非对象值，而且典型地只对字节数组有意义：结构体可以含有填充字节而其值不确定，存储于联合体最近存储成员后的任何字节的值是不确定的，且一个类型可以对相同值拥有二种或多种表示（对于 +0 和 -0 或 +0.0 和 –0.0 的相异编码、类型中不确定填充位）。 简单的应用： 6. memset void *memset( void *dest, int ch, size_t count ); 定义于头文件 &#x3C;string.h> 复制值 ch （如同以 (unsigned char)ch 转换到 unsigned char 后）到 dest 所指向对象的首 count 个字节。 若出现 dest 数组结尾后的访问则行为未定义。若 dest 为空指针则行为未定义。 参数: dest - 指向要填充的对象的指针 ch - 填充字节 count - 要填充的字节数 返回值: dest 的副本，本质为更底层操作的临时内存地址，在实际操作中不建议直接使用此地址，操作完成以后，真正有意义的地址是dest本身。 简单应用："},{"title":"大端小端与整数存储","slug":"c-notes/16-endianness","permalink":"/kb/posts/c-notes/16-endianness","category":"c-notes","description":"大端与小端存储模式的概念、判断方法与面试题","date":"2026-06-16T00:00:00.000Z","content":"1.如何用程序判断自己的机器是大端还是小端？ 通常情况下，我们的计算机都是小端存储模式。 小端：数字的低位存储到内存的低地址上。 大端：数字的低位存储到内存的高地址上。 我们在 VS 中创建一个临时变脸 然后打开调试器，看到变量 a 在内存中是这样存储的： 对于 Vs 调试中内存窗口的这行信息应该如何理解呢？它就表示： 十六进制数每两位表示一个字节，地址也是十六进制数；int 类型在 32 位机器上大小为 4 个字节。 如何理解十六进制数每两位表示一个字节？ 十六进制数每一位的取值范围是 0 ~ 15，表示 16 种不同可能，对应 4 个二进制位（0000 ~ 1111），所以每一位十六进制可以表示 4 个二进制位，那么两个十六进制位就表示 8 个二进制位，也就是 1 个字节。 可以看到，在我的机器上，低位 44 存储在 低地址（0x0133FC50）上，所以我的机器是 小端存储模式。 如果是大端存储模式，变量 a 在内存中的存储应该如下图所示： 现在，让我们用程序来验证一下我们的机器到底是大端还是小端。 方法一 方法二 2.关于整数类型存储的面试问题 以下问题大家可以先独立思考一下，看看如果真的面试官问你，你能不能正确的回答并清晰的讲出其中的原理。 1 请问，printf 函数会打印出什么内容？并解释原因。 signed char 与 char 表示同一种类型，原理一样 2 请问，printf 函数会打印出什么内容？并解释原因。 你想到了吗？ 我们还是按照上面的思路分析： 3 请问，printf 函数会打印出什么内容？并解释原因。 神奇吗？并不神奇。 原因就在于“截断”时得到的二进制序列是一模一样的，后面的操做是相同的。 另外说一句，char 的范围是 -128 ~ 127，所以上面的 char 型变量 a 溢出了。 试着想想下面的 printf 函数又会输出什么呢？ 4 首先，i 与 j 相加时， int 转换为 unsigned int 。 5 请问：下面的程序会输出什么？ 这个问题的关键点就是在 i == 0 时。如果 i 的类型是 int ，毫无疑问，for 循环会在这里结束。可是，现在 i 的类型是 unsigned int。 我们知道， i-- 等同于 i -= 1 ，也就是 i = i - 1 。对于编译器来说，其实这个操作是 i = i + (-1) ，我们知道， -1 的补码是： 11111111 11111111 11111111 11111111 当它与 0（i）相加时，i 的补码就变成了全 1。问题就在于，这时候 i 是 unsigned int 类型，这个全 1 的补码的含义并不是 -1 而是 unsigned int 的最大值。所以循环条件 i >= 0 依然满足。 换句话说，对于 unsigned int 类型的 i 来说， i >= 0 是恒成立的。 所以答案是无限循环。 6 7 这个情况与例5相同。 3.浮点数 浮点数我们不做过多说明，详情我们在【C入门到精通】讲过。 我们着重强调一下，对于 2 个浮点数的比较 来说，不能像整型那样直接比较，应该引入一个误差范围，比如： 如果本文你有地方没有看懂，推荐阅读以下文章，可以帮助你理解 ： 一文看懂枚举&#x26;结构&#x26;联合"},{"title":"指针进阶","slug":"c-notes/17-pointers-advanced","permalink":"/kb/posts/c-notes/17-pointers-advanced","category":"c-notes","description":"指针进阶：指针数组、数组指针、函数指针与回调函数","date":"2026-06-16T00:00:00.000Z","content":"指针进阶 目录 前言 指针的概念 指针就是个变量，用来存放地址，地址唯一标识一块内存空间。 指针的大小是固定的4/8个字节（32位平台/64位平台）。 指针是有类型，指针的类型决定了指针的+-整数的步长，指针解引用操作的时候的权限。 1、字符指针 字符串的 数组 与 指针 表示的区别 请看下面这段代码，猜测会输出什么： 1-1.c 输出： 我们不着急解释原因，我们再来看一下下面这个例子： 1-2.c 1-3.c 试着思考：1-2.c 和 1-3.c 输出的结果一样吗？ 为什么 1-3.c 程序会直接崩溃呢？ 这是因为字符串字面量 \"hello\" 存储在 常量区 ，该区域内的常量是 只读 的，不能被修改。 为了更加清楚的了解上面三个程序的原理，不妨看看下图： 2、指针数组 3、数组指针 Ⅰ：定义 数组指针是 指针 。 ** [] 的优先级高于 * ，所以 () 不能省略 ** Ⅱ：数组名 与 &#x26;数组名 3-1.c 输出： 实际上： &#x26;arr 表示的是数组的地址 ，而不是数组首元素的地址。 数组的地址+1，跳过整个数组的大小，所以 &#x26;arr+1 相对于 &#x26;arr 的差值是40. Ⅲ：数组指针的使用 3-2.c 输出： 4、数组参数 &#x26; 指针参数 Ⅰ：一维数组传参 4-1.c 我们先来看一下 main 函数 请判断以下五个函数的参数写法是否正确： 这五种写法都是可以的。 Ⅱ：二维数组传参 4-2.c 二维数组传参，只有第一个 [] 内的数字可以省略，其他的都不能省略 5、函数指针 Ⅰ：定义 5-1.c 输出： 输出的两个地址是 main 函数的地址。 如何理解函数指针？ 当你写完并保存一个 .c 文件后，这个文件是存储在计算机硬盘中的。编译 c文件后生成的 .exe(可执行文件)也是在硬盘上。 当你双击 .exe 文件时，操作系统就会把这个文件加载到 内存中 ，并创建一个对应的 进程。 对于函数指针来说，最大的用处就是可以直接调用函数。 如何保存函数地址呢？ 5-2.c Ⅱ：函数指针数组 我们要将函数的地址存放到一个数组中去，这个数组就叫 函数指针数组。 定义方法： int (*parr[10])(); parr 先与 [] 结合，说明 parr 是数组，数组的内容是 int (*)() 类型的函数指针。 应用：转移表 5-3.c 计算器 应用：回调函数 帮助理解： 6、指针和数组笔试题 环境： 32 位机器 第一组 答案： 第二组 答案： 第三组 答案： 第四组 答案： 指针为什么也可以用 [] 运算符？ 对于指针 int* p = \"abc\"; p[1] 等价于 *(p + 1) 这是因为数组很多时候可以隐式转换成指针。 重点注意： printf(\"%d\\n\", strlen(&#x26;p)); &#x26;p 的类型是 char** ,但是C语言会将其隐式类型转换成 char* ，但是 strlen 访问的是地址p的内存空间，那这其实是未定义行为。 第五组 答案： 重点注意： printf(\"%d\\n\",sizeof(a[0]+1)) printf(\"%d\\n\",sizeof(&#x26;a[0]+1)) a[0] 与 &#x26;a[0] 的差异比较： printf(\"%d\\n\",sizeof(*(&#x26;a[0]+1))); 我们来一步一步分析： a[0] -> int[4] ; &#x26;a[0] -> int (*)[4] ; &#x26;a[0] + 1 -> int (*)[4] ; *(&#x26;a[0] + 1) -> int[4] printf(\"%d\\n\",sizeof(a[3])) sizeof 是一个运算符，并不是函数。它在预编译时期替换。而我们说的“数组下标访问越界”前提条件是 内存 访问越界，这个时期是程序运行时。a[3] 就是 int[4] 类型，所以就是 16。哪怕你写 a[100]都可以。 printf(\"%d\\n\", 16) 是程序运行时执行的语句。 关于 const 对于第一种写法，*p 是不能改变的；对于第三种写法，地址 p 是不能被改变的。 7、指针笔试题 Ⅰ a + 1 ：a 隐式转换成 指针，指向 首地址后移 4 个字节。（a 隐式转换后是 int* 类型，它指向的 int 大小是 4 个字节，所以后移 4 个字节） &#x26;a 的类型是 int(*)[5] ,所以 &#x26;a + 1 后移 int[5] 的长度 所以最后输出的是：2，5 Ⅱ p + 0x1 p 加十六进制的 1，p 所指向的结构体大小是 20，所以 p 会增加 20 。但是注意 %p 输出的是 16 进制的地址，所以输出的是 0x100014 (unsigned long)p + 0x1 p 被强转成了一个数，所以输出的就是 0x100001 (unsigned int*)p + 0x1 p 被强转成了一个 int* 类型的指针，所以输出的是 0x100004 Ⅲ ptr1[-1] : 前面我们说过，这个操作相当于 *(ptr1 - 1) (int)a + 1 是将 a 先强转为 int 然后再加 1，所以 a 仅仅增加了 1 个字节 Ⅳ p[0] -> a[0] [0] ，所以输出的是 0 吗？ 并不是，注意看 a[3] [2]大括号内的内容，里面是圆括号而不是大括号，这是 逗号表达式 。 所以，a[0] [0] == 1 Ⅴ 指针（同类型）相减的意义是 两个指针之间间隔的元素个数 &#x26;p[4][2] -> 数组中的第 19 个元素（4 * 4 + 3） &#x26;a[4][2] -> 数组中的第 23 个元素 (4 * 5 + 3) 答案：FFFFFFFC,-4 Ⅵ &#x26;aa 的类型是 int(*)[2][5] ，所以 &#x26;aa + 1 指向的是整个数组后面的内存 。所以 *(ptr1 - 1) 的值是 10 aa aa + 1 让 aa 隐式转换为 int(*)[5] ,所以 aa + 1 指向的是元素 6 所在的地址。所以 *(ptr2 - 1) 的值是 5 Ⅶ Ⅷ 单目运算符从右向左依次运算。 cpp[-1] [-1] 可以理解为：(cpp[-1])[-1]，即：从左向右依次计算。"},{"title":"判断大端小端的程序","slug":"c-notes/18-detect-endianness","permalink":"/kb/posts/c-notes/18-detect-endianness","category":"c-notes","description":"用C语言程序判断计算机的大端小端存储模式","date":"2026-06-16T00:00:00.000Z","content":"如何用程序判断自己的机器是大端还是小端？ 通常情况下，我们的计算机都是小端存储模式。 小端：数字的低位存储到内存的低地址上。 大端：数字的低位存储到内存的高地址上。 我们在 VS 中创建一个临时变脸 然后打开调试器，看到变量 a 在内存中是这样存储的： 对于 Vs 调试中内存窗口的这行信息应该如何理解呢？它就表示： 十六进制数每两位表示一个字节，地址也是十六进制数；int 类型在 32 位机器上大小为 4 个字节。 如何理解十六进制数每两位表示一个字节？ 十六进制数每一位的取值范围是 0 ~ 15，表示 16 种不同可能，对应 4 个二进制位（0000 ~ 1111），所以每一位十六进制可以表示 4 个二进制位，那么两个十六进制位就表示 8 个二进制位，也就是 1 个字节。 可以看到，在我的机器上，低位 44 存储在 低地址（0x0133FC50）上，所以我的机器是 小端存储模式。 如果是大端存储模式，变量 a 在内存中的存储应该如下图所示： 现在，让我们用程序来验证一下我们的机器到底是大端还是小端。 方法一 方法二 如果本文你有地方没有看懂，推荐阅读以下文章，可以帮助你理解 ： 一文看懂枚举&#x26;结构&#x26;联合"},{"title":"指针的运算 详解","slug":"c-notes/19-pointer-operations","permalink":"/kb/posts/c-notes/19-pointer-operations","category":"c-notes","description":"指针的加减运算、指针相减、NULL指针与void指针","date":"2026-06-16T00:00:00.000Z","content":"指针的运算 详解 指针的运算 指针加减 常量 请看下面的程序，猜测一下结果： 运行结果： 可以看到， a 与 a + 1 和 a - 1 都差了四个字节 指针加减常量 加减的大小为 sizeof(类型) * 常量 再试试 char 类型？ 结果如我们所料： 相差大小 为 1 指针 - 指针 先来看一段程序吧： 指针相减 结果会是 指针相差的大小吗？看结果： 指针 减 指针 意义是 两个地址之间相隔的单元格数 也可以理解为：指针相差的大小 / sizeof（类型） 如果想输出两个指针 相差的距离（大小）只需要将变量类型 更改成普通类型，如下： 输出结果： 普通类型是无法进行解引用操作的 总结一下 指针 可以 加减常数，指针之间可以相减，可以比较（如：> == &#x3C; >=等） 但是指针不能乘除，相加 这是没有意义的 举个很简单的例子，时间可以相减，但是时间乘除或者相加有什么意义呢？ NULL 通过前面的学习，我们知道：内存中的地址有很多编号。如果你的机器是 32 位， 那么内存范围是： 0 ~ 2^32 -1 (32位2进制数全1) 最大值大约为 4GB NULL其实就表示 0地址 补充个小知识点： 1kB=1024B =2^10(次方是二进制形式) 1MB=1024kB =2^20 1GB=1024MB =2^30 1TB=1024GB =2^40 NULL有什么用？ 0地址规定为我们不能写入的地址，你的指针不指向 0地址，如果你的指针指向了 0地址 那么程序运行时会崩溃。基于这个特点，0地址 也就是NULL有了很重要的功能： 函数返回 NULL指示错误 防止野指针（什么是野指针？参考 C语言复习巩固（五） 指针（初阶） ）。用NULL初始化指针，如果指针使用时没有指向任何实际地址，程序崩溃。 NULL类型时 void * 可以设置任何类型为NULL 下面的程序是官网上讲NULL时给出的例子： void* void* 表示 不知道指向什么类型的 指针 比如： 这么写并没有改变 p 所指向的变量的类型， 而是可以让程序用不同的眼光通过 p看它所指的变量。 指针类型的作用 指针的类型决定了指针向前或者向后走一步有多大 指针的类型决定了，对指针解引用的时候有多大的权限（能操作几个字节） (具体示例参考 C语言复习巩固（五） 指针（初阶） ) 更多关于指针的可以参考我的其他篇文章： C语言复习巩固（五） 指针（初阶） 指针运算详解，const详解，NULL？void* 指针基础"},{"title":"C语言实现重载、多态与模板","slug":"c-notes/20-polymorphism-in-c","permalink":"/kb/posts/c-notes/20-polymorphism-in-c","category":"c-notes","description":"C语言通过va_list实现重载、函数指针实现多态、宏实现模板","date":"2026-06-16T00:00:00.000Z","content":"C 语言实现重载，多态和模板 为什么 C 语言不支持重载 这和 C 和 C++ 的函数名称修饰有关。编译（并汇编）一个 C 和 Cpp 程序，使用 objdump -dS 命令查看 ELF 格式文件发现： gcc 编译器下，C 程序的函数名没有变化，但是 Cpp 程序的函数名称有了参数相关的后缀，这使得重载的 sum 函数底层的函数名称不同，编译器可以区分。 C 语言实现重载 函数 描述 col 3 is right-aligned va_list arg_ptr 定义一个可变参数列表指针 va_start(arg_ptr, argN) 让arg_ptr指向参数argN va_arg(arg_ptr, type) 返回类型为type的参数指针,并指向下一个参数 va_copy(dest, src) 拷贝参数列表指针,src->dest, va_end(arg_ptr) 清空参数列表，并置参数指针arg_ptr无效。每个va_start()必须与一个va_end()对应 参考文章： http://locklessinc.com/articles/overloading/ 上面的代码，我们可以解析函数参数，然后选择调用 va_overload2() 或 va_overload3() 。POSIX 的 open() 函数在你的机器上也许有着类似的实现方式。 另一种 va_args 常见的用法是接受数量没有限制参数，没有直接的可接受数量的说明符。通过 NULL 来结束参数列表，我们可以解析任意对我们函数的输入。 上面的函数将会打印传递给他的 C 字符串。无论最后一个指针是否是 NULL 。这里的问题是需要记得最后在参数列表后加上 NULL 。如果它丢掉了，上面的函数将会把栈上的值当作 const char* 指针然后尝试将其打印出来。这回引发未定义行为，这可能会使这个程序崩溃。 一种解决办法是明确的说明有多少参数存在，将最后的参数 NULL 删除。 让用户人为的确定个数是不便且易错的。 这段我感觉自己翻译的不是很好，有兴趣可以自己去看原文 C 语言实现多态 下面程序的本质就是 C++ 多态的实现 C 语言实现模板 ## 运算符可以将两个表达式“拼”起来 看一下预处理后的代码： 参考资料： 《C 语言程序设计 —— 现代方法》 http://locklessinc.com/articles/overloading/ https://blog.csdn.net/gatieme/article/details/50921577 https://www.cnblogs.com/qingergege/p/9594432.html"},{"title":"数组基础知识与二维数组","slug":"c-notes/21-array-basics","permalink":"/kb/posts/c-notes/21-array-basics","category":"c-notes","description":"数组的定义、初始化、二维数组与数组作为函数参数","date":"2026-06-16T00:00:00.000Z","content":"各位同学，你觉得你数组学会了吗？不妨看看下面的问题，你能看一眼程序就回答上来吗？ 引子：观察下面的程序，这个程序有安全隐患吗？ 答案是有的 while 循环种没有限制 cnt 有可能导致 数组越界 ！ 不能快速找到错误和找不到错误其实是一样的 ，因为不能快速找到这个错误说明你没有深刻的理解数组。这种基础的概念如果没有渗透到你的脑中，并不能说自己学好了数组吧！我学了一学期C，课设1000行代码都是自己独立完成的。依然没有立刻看出这个问题来，我也是自愧没有学好啊！ 数组特性与一个注意 1.数组是一种容器（放东西的东西） 2.基本特点是： 其中所有元素具有相同的数据类型 一旦创建，不能改变大小 在内存中连续依次排列 3.注意： 数组作为函数参数时，往往必须再用另一个参数来传入数组的大小 我们常用 sizeof（arr） / sizeof（arr[0]） 来判断数组元素个数 但是这种情况下不能在函数中用 sizeof(arr) 判断数组大小 例1 //写一个程序，输入数量不确定的【0 ~ 9】范围内的整数，统计每一种数字出现的次数，输入 -1 表示结束 方法一：先看一个基础做法 方法2 当我们要统计的数是像 0 ~ 9 这样连续的数时，我们可以把数组下标与这些数一一对应起来，可以更方便，快捷 思考一下：字符常量可以做数组下标吗？ 例如，形如arr['a'] 可以吗？ 如果可以，那么当我们想要统计字符串种某个字母（或者任何ASCII码表上存在的字符）的具体个数时，就会很方便。可以自己尝试着写一下哦~思路和上面的数字判断差不多。 我写的供大家参考： 代码 例2 二维数组 1.初始化 int a[][3] = {{1, 1, 1}, {2, 2, 2}, {3},} 1.列数必须给出，行数可以空出 2.每行都有有一个单独的大括号 { } （可以不写，建议写上） 3.最后的逗号可以写上，老一代程序员们约定俗成的经验（如果你写上，可以装装逼） 4.缺省表示补零 5.强烈推荐的另一种书写方式： 这样写的好处不言而喻，更加形象立体 练习题 若有定义：int a[2][3].则下列不越界的正确访问有： A: a[2][0] B: a[2][3] C: a[1 > 2][0] D: a[0][3] 以下程序片段输出的结果是： A: 369 B: 不能通过编译 C：789 D：能编译，但是数组下标越界了 3 若有int a[][3] = {{0}, {1}, {2}}; a[1][2] 的值是？ 答案：C A 0"},{"title":"1.为什么要声明函数？","slug":"c-notes/22-function-basics","permalink":"/kb/posts/c-notes/22-function-basics","category":"c-notes","description":"函数的声明与定义、作用域、生存期与递归","date":"2026-06-16T00:00:00.000Z","content":"相信在学校同学们看谭老师的教材的时候已经对函数有了“初步的认识”。 但是，如果你没有理解下面这几个例子，那并不能说你对函数入门了。 1.为什么要声明函数？ 上面代码 void swap(); 就是swap函数的声明 把对swap的声明写在main函数的上面是因为： C的编译器从main函数的第一行开始，自上而下的分析你的代码。 在看到 swap（a， b） 时，它需要知道 swap（） 的样子 也就是swap（）的 返回类型 ， 参数类型 ， 参数个数 这样它才能检查你对swap（）的调用是否正确 接下来我们讨论，如果编译器不知道函数的返回类型，参数类型，参数个数的话，编译能否继续进行？ 下面我们看两个例子，分析编译器能否正确执行我们的代码 第二种情况会报错： “swap”未定义，假设外部返回int “swap”重定义，不同的基类型 在没有对函数进行声明的情况下 旧标准会假设你所调用的函数所有的参数和返回类型都是int类型。 如果不是，就会发生上述的问题 2.定义函数的方式 请观察下面三种函数声明，这三种写法哪些是正确的？ 答案是，这三种写法都是可以的。 事实上，编译器看的是第三种写法。而我们常用的第一种写法是为了让我们自己和别人更好的理解我们写的代码，这是良好的写代码习惯。 然而还有这样一种声明方式，大家想一下，编译器可以识别吗？ 答案是可以的。 这种写法可以说是这几种写法中最方便的写法，那么这种写法是不是无懈可击的呢？ 如果我改变了临时变量a，b的类型再试试呢？ 这回，输出的x，y就不我们所期望的了。为什么会这样？我在这里给出简单解释： main函数 执行到调用swap函数的地方的时候，由于我们在声明 swap函数 的时候并没有指定参数的类型，所以程序会猜测这两个变量的类型为 int 类型。而在函数定义的时候类型却为 double ，所以程序就有点“疑惑”了。 所以，这种类型的写法是错误的！ 3.了解概念：块（block） 简单的来说，一个大括号 { } 就是一个块 4.作用域和生存期的关系 如果变量在作用域内，那么它的生存期一定存在 如果变量的生存期还没结束，它不一定在作用域内 5.没有参数时 当一个函数没有参数，应如何表示呢？ void f(void) 还是 void f() ? void f(void) 表示函数没有参数 void f() 表示参数未知 6.函数括号内的逗号与逗号运算符 我们思考一下， f(a,b) 会不会引发歧义？ 如果有人认为这个逗号表示的是逗号运算符怎么办？ f(a,b) 表示函数f有a，b两个参数 f((a,b)) 这个逗号才是逗号运算符，表示函数f只有一个参数b 7.函数内是否可以定义函数 C语言不允许函数内嵌套定义 8.return(i) 这样写会引起歧义，它的意思究竟是返回i还是调用return函数？ 9.关于main函数 main函数也是函数，是被其函数调用的 main函数返回值是有意义的 int main（） 还是 int main（void） ?"},{"title":"数据类型、变量与常量","slug":"c-review/01-data-types-variables","permalink":"/kb/posts/c-review/01-data-types-variables","category":"c-review","description":"C语言数据类型大小、变量定义与作用域、常量与字符串","date":"2026-06-16T00:00:00.000Z","content":"数据类型及大小 char 字符型 1 byte short 短整型 2 byte int 整型 4 byte long 长整型 4 byte long long 更长的整型 8 byte float 单精度浮点型 4 byte double 双精度浮点型 8 byte long double 16 byte 看大小的程序： 定义变量的位置 有的编译器会在这里提示错误，因为c规定对变量的声明应该在函数的开头。 修改后： 这样应该就不会有问题了 关于变量的作用域和生存期 注意变量的作用域 外部变量引用 全局变量的注意事项：先声明，后引用 大家思考下面这个代码sum函数是否可以算出a+b的和 运算结果并不是我们像要的，应该如何修改呢？ 只需要将对 a的声明放在要调用a的sum函数前即可 也有例外，供大家思考 总而言之，一定要在引用变量前声明变量，就算是全局变量 常量的类型 1.字面常量 如：3.14,\"abc\" ,'a' 2.const 修饰的常变量 声明方式const int num = 10 一般来说常变量无法再赋其他值 常变量依然是变量，并不是常量（int arr[num] = {0}；这个语句是有错误提示的，证明num并不是常量） 3.#define定义的标识符常量 4. 枚举常量 enum 枚举类型常量 如果没有赋予其他初始值默认从0开始递增。 字符串的一个重要问题 思考一下上述两个printf函数会输出什么？是否一样？ 结果是 arr1可以正常输出 但是arr2却在输出abc后出现了乱码（这个乱码是随机的，可能出现也可能不出现） 为什么会这样？ 因为c语言中会在字符串后面自动加上空字符'\\0'，它代表着字符串的结束 再输出arr2过程中由于后面没有终止符，系统会继续向下搜寻，而后面是什么内容是未知的，因此会出现乱码。 字符串的长度都是3 转义字符的重要用法 如果我想输出目录“ c:\\test\\090\\test.c”用下面代码输出是否可以？ 输出结果竟然是：c: est8 est.c 为什么会这样？因为‘\\’是一个转义字符，‘\\t’为水平制表符 会进行缩进 那么应该正确的输出我们想要的结果呢？ 我们只需要再''之前再加上一个''再次进行转义就好了 \\ddd ddd表示1~3个八进制的数字 \\xdd dd表示2个十六进制数字 注：八进制数位上最大值为7 以上就是本次关于c语言要点的归纳，感谢观看"},{"title":"数组与操作符基础","slug":"c-review/02-arrays","permalink":"/kb/posts/c-review/02-arrays","category":"c-review","description":"数组、操作符、关键字、typedef、static、define综合复习","date":"2026-06-16T00:00:00.000Z","content":"数组 1.数组的赋值 int arr[10]={1,2}的意义为将数组arr前2个元素初始化为1，2后面的元素初始化为0。 操作符 1.算数操作符 + - * / % \" / \"除法运算 向下取整 \" % \"求余运算 计算余数 2.移位操作符 >> &#x3C;&#x3C; >> 左移 &#x3C;&#x3C; 右移 表示2进制位左移右移 为方便理解，可以参考下图： 3.位操作符 &#x26; | ^ a.按位与 与的原理等同于数学中的且 按位就是按照变量的2进制位 b.按位或 101 5 011 3 111 7 c.按位异或 101 5 011 3 110 6 赋值操作符 = += -= *= /= &#x26;= ^= |= >>= &#x3C;&#x3C;= 单目操作符 ！ 逻辑反操作 - 负值 + 正值 &#x26; 取地址 sizeof() 操作数的类型长度（以字节为单位） ++ -- * 间接访问操作符(解引用操作符) (类型) 强制类型转换 ~ 对一个数的二进制按位取反 关系操作符 > >= &#x3C; &#x3C;= != ==(不要写成=) 逻辑操作符 &#x26;&#x26; || 条件操作符 exp1 ? exp2 : exp3 逗号表达式 exp1, exp2, exp3, …expN 从左到右依次计算，最终值等于最后一个表达式 下标引用、函数调用和结构成员 [] () . -> 常见关键字 auto break case char const continue default do double else enum exte rn float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while 1.typedef 类型重命名 2.static 代码1 中会输出十个2。而代码2种则会输出0~9 关于static 的思考 对于静态变量的声明应该在static语句中声明 a） 一个全局变量被static修饰，使得这个全局变量只能在本源文件内使用，不能在其他源文件内使用。 extern int a （所要执行的源文件内声明） static int a （包含a的源文件内声明） b） 一个函数被static修饰，使得这个函数只能在本源文件内使用，不能在其他源文件内使用 。 extern int add(int x,int y) 3.define定义的常量和宏 指针 指针变量也需要地址存放。 结论：指针大小在32位平台是4个字节，64位平台是8个字节。 printf(\"%p\\n\", p) 输出地址 结构体"},{"title":"分支和循环语句","slug":"c-review/03-branches-loops","permalink":"/kb/posts/c-review/03-branches-loops","category":"c-review","description":"if/else、switch、while、do-while、for等分支与循环语句","date":"2026-06-16T00:00:00.000Z","content":"目录 分支和循环语句 顺序结构 分支语句（选择结构） if语句 if语句建议以以下两种方式书写： [ 表达式内的关系操作符与逻辑操作符：](# 表达式内的关系操作符与逻辑操作符：) [ else：](# else：) switch语句： [ 循环语句：](# 循环语句：) while语句： while语句中的break与continue： [ do...while语句](# do...while语句) [ for语句：](# for语句：) 答案： 分支和循环语句 顺序结构 分支语句（选择结构） if语句 语法结构： 1.if（表达式） ​ 语句； 2.if（表达式） ​ 语句1； else ​ 语句2； 3.if（表达式） ​ 语句1； else if（表达式） ​ 语句2； else ​ 语句3； * 表达式结果为非0（真）则执行语句 { }代表代码块 if语句建议以以下两种方式书写： 注意：“==”与“=”的区别 表达式内的关系操作符与逻辑操作符： 示例： 18 &#x3C;= age &#x3C;= 30的程序中判断过程是： 18 &#x3C;= age （40） 为真 所以左边部分变为1 1 &#x3C;= 30 为真 值为 1 所以if内的语句可以被执行 正确写法： else： else 与相邻最近的if匹配（就近） else会与第二个if匹配而不是第一个 正确的写代码规范十分重要，如下这样写就不会有问题： 当然也是可以简化的： switch语句： switch(整型常量表达式){ ​ case 1: ​ ........; ​ break; ​ case 2: ​ ........; ​ break; ​ case 3: ​ ........; ​ break; ​ ... ​ case n: ​ ........; ​ break; ​ default: ​ ........; ​ break; } 1. switch中必须为整型的常量表达式 2 *.break语句建议在每个case后加上，避免以后修改时忘记添加* 3.default只能出现一次 思考： 循环语句： while语句： while(表达式){ ​ 循环语句； } while语句中的break与continue： 以下给出三个示例 示例1： 示例2： 程序没有结束，这是一个死循环 示例3: do...while语句 do{ ​ 循环语句； }while（表达式）；//分号不要忘记 for语句： for（ 初始化部分；条件判断部分；调整部分 ）{ ​ 循环语句； } 不可在for 循环体内修改循环变量，防止 for 循环失去控制。 建议for语句的循环控制变量的取值采用“前闭后开区间”写法。 \\1. 请问下面的代码中循环体执行几次？ 2.下面的代码会输出什么？ 代码应该书写规范： 答案： m=5，n=3 0 1 2 3 4 5 6 7 8 9（因为在第一次大循环中j已经变为10 后面的大循环中第二个循环是没有输出的）"},{"title":"什么是函数？","slug":"c-review/04-functions","permalink":"/kb/posts/c-review/04-functions","category":"c-review","description":"函数的定义、调用、参数传递与返回值","date":"2026-06-16T00:00:00.000Z","content":"什么是函数？ 这个大家自己思考吧（没必要去复制粘贴百度的定义到这里来。每个人有自己的理解，这个东西多用就会了） 函数（function） 通过实参（argument）初始 形参（parameter） 执行完函数体（function body） 返回（return value）一个值。（或者不返回） 函数类型 1.库函数 提供给大家一个学习库函数的网站： http://www.cplusplus.com/reference/ 2.自定义函数 比如我们常用的 int main(){ } 1.这个 int 就是返回值的类型 我们一般在main函数最后一行加 return 0 2.main 是函数名 可以自己起 3.（）括号内 可以放形式参数 也可以不写 4.大括号内就是函数体 写函数的功能 定义函数 强调一下函数的声明与定义不一样！ 声明就像你在main函数开头初始化变量 它的作用就是让main函数顺序执行到调用语句时知道这个你在前面说过，不至于让main函数很懵逼 只用写上面的1，2，3 如：int Max(int a, int b); 定义就需要具体实现这个函数的功能 1，2，3，4都需要写完 形参与实参 这个不同大家自己百度就行。 自己可以在vs里调试看看你设置的形参与实参的地址（形参与实参地址时不一样的） 嵌套调用 可以类比数学的复合函数f(g(x)) 链式访问 例1： arr数组在经过strcat后变成了\"hello world\" 我们知道strlen读取的长度是11（不懂为什么可以按照我给的网站去查strlen ,strcat的用法，里面说的很到位） 例2： 最后输出是7（其实直接看谁最大就好了，不知道原理也没事） 例3： 我们从最内层开始看起： printf（\"%d,43\"）它会输出 43 printf函数会返回它打印的字符数 所以它返回 2 我们看下一层： printf(\"%d\",2); 这时打印 2 返回 1 继续下一层： printf(\"%d\",1); 打印 1 返回 1 同理最外层会打印1 所以最总结果是：43211 总结一下：链式访问需要关注函数的返回值 递归 什么是递归？ 程序调用自身的编程技巧称为递归（ recursion） 递归的两个必要条件 1.存在限制条件，当满足这个限制条件的时候，递归便不再继续。 2.每次递归调用之后越来越接近这个限制条件 举个例子： 求第 n 个斐波那契数列 先用数组法 以我的经验 普通的做法往往能给递归法找到规律 递归法： 但是递归法做有的问题并不聪明==（比如这个问题） 因为如果给的n很大它会重复计算很多次，不断调用意味着更大的空间。可能造成栈溢出（stack overflow） 所以下面这个做法也许是解决这一类问题的好做法 迭代法："},{"title":"指针基础与二级指针","slug":"c-review/05-pointers","permalink":"/kb/posts/c-review/05-pointers","category":"c-review","description":"指针的概念、指针运算、二级指针与指针与数组的关系","date":"2026-06-16T00:00:00.000Z","content":"这一节带大家简单了解一下与指针，希望对大家有帮助 指针 1.什么是指针？ 在计算机科学中，指针（Pointer）是编程语言中的一个对象，利用地址，它的值直接指向 （points to）存在电脑存储器中另一个地方的值。由于通过地址能找到所需的变量单元，可以说，地址指向该变量单元。因此，将地址形象化的称为“指针”。意思是通过它能找到以它为地址 的内存单元。 指针的大小： 1.指针是用来存放地址的，地址是唯一标示一块地址空间的。 2. 指针的大小在32位平台是4个字节，在64位平台是8个字节。 指针类型： char *p = NULL; short *p = NULL; int *p = NULL; long *p = NULL; double *p = NULL; ....... null 是什么我后面会解释给大家 既然指针大小已经固定的，那么要这么多指针类型有什么用呢？或者说我们可不可以有char*来表示所有的指针类型？ 指针类型的作用 1.指针的类型决定了指针向前或者向后走一步有多大 可以看到：字符指针加一只增加了一个字节；而整型指针增加了四个字节 ​ （char*）为强制类型转换 因为n是整型所以n的指针应该是int* 2.指针的类型决定了，对指针解引用的时候有多大的权限（能操作几个字节）。 比如： char* 的 指针解引用就只能访问一个字节，而 int* 的指针的解引用就能访问四个字节。 0x11223344是16进制数 16进制数一位就是四个二进制位 所以16进制数 每两位就代表一个字节 所以地址一般有16进制位表示 也就是四个字节 野指针 概念： 野指针就是指针指向的位置是不可知的（随机的、不正确的、没有明确限制的）指针变量 在定义时如果未初始化，其值是随机的，指针变量的值是别的变量的地址，意味着指针指向了一 个地址是不确定的变量，此时去解引用就是去访问了一个不确定的地址，所以结果是不可知的。 1.野指针成因 1.指针未初始化 2. 指针越界访问 3. 指针指向的空间释放 举个简单例子 test被调用完之后临时变量a已经被释放，所以*p就是野指针。 动态空间开辟会详细讲。 如何规避野指针 \\1. 指针初始化 \\2. 小心指针越界 \\3. 指针指向空间释放即使置NULL \\4. 指针使用之前检查有效性 防止指针为初始化我们可以这样做： 指针运算 1.指针+-整数 2.指针减指针 这里介绍strlen实现： 方法1： 方法2： 方法3： 方法4： 我们来看一下vs strlen库函数是如何写的，它的路径如下： 是不是和方法4差不多呀 3.指针关系运算 以初始化数组为例 方法1： 方法2： 实际在绝大部分的编译器上是可以顺利完成任务的，然而我们还是应该避免这样写，因为标准并不保证 它可行。 大家可以根据上面的例子细品一下这句话： 标准规定： 允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针比较，但是不允许 与指向第一个元素之前的那个内存位置的指针进行比较。 二级指针 指针数组 例1： 12 = 3*4（指针大小） 例2： 练习： 1： 2："},{"title":"C语言操作符详解","slug":"c-review/06-operators","permalink":"/kb/posts/c-review/06-operators","category":"c-review","description":"算术、位运算、赋值、关系、逻辑等C语言操作符详解","date":"2026-06-16T00:00:00.000Z","content":"目录 [TOC] 正文 一 算数操作符 + - * / % : % 左右两边的数必须都为整数 二 移位操作符 >> : 右移 &#x3C;&#x3C; : 左移 例1：b = 20 例2：b = -4 注意1： 左移直接在空的地方补0 如例1 右移有两种情况： 1.逻辑位移 补0 2.算数位移 补1 如例2（一般都是这种情况） 注意2： 移位操作符，不要移动负数位，这是标准未定义。 如：num >> -1//error 三 位操作符 &#x26; (按位与) | （按位或） ^ （按位异或） 例3：按位与 例4：按位或 例5：按位异或 例6：异或的应用——交换两个值的内容 方法1： 方法2：（如果a，b很大可能会溢出） 方法3(异或法)： 例7：怎么求一个二进制位中1的个数 unsigned的作用： unsigned就是将这个二进制数最高位的符号位变成计数位。下面我们举个例子帮大家理解一下 如果我们输入的是-1 -1%2 == -1 -1/2 = 0 这样输出的count为0 但是我们知道-1的补码是11111111111111111111111111111111 这样我们的代码就局限在正整数 如果加上unsigned 虽然我们输入的是-1 但是程序计算是是按照 unsigned int 的最大值，这样就避免了这个问题 更多位运算相关示例： https://github.com/hairrrrr/linux.ccode/tree/master/Bit/ClassCode/2020-1-7%EF%BC%888%EF%BC%89 四 赋值操作符 = a = b = c 它的意义是将c的值赋给b，再将b的值赋给a。其实这样理解不够准确，其实应该这么写： a = （b = c） 先将c的值赋给b 然后将这个整体，即b的值赋给a 五 复合赋值符 += -= *= \\= %= >>= &#x3C;&#x3C;= &#x26;= |= ^= 六 单目操作符 ！ \\- = &#x26; （取地址） sizeof （操作数的类型长度） ~ （对一个数的二进制位按位取反） -- ++ （前置，后置） * （解引用） (类型) （强制类型转换） 例8：！的应用 应用！与flag来判断情况做出选择 if(flag){ flag为真进入循环; } if(!flag){ flag为假进入循环; } printf函数打印格式 %#p 0XCCCCCC %p 00CCCCCCC %x cccccc %X CCCCCC 例9：~ 的应用 七 关系操作符 > >= &#x3C;= != == 注意：不要将 ‘ == ’ 写成 ‘ = ’ 八 逻辑操作符 &#x26;&#x26; || 例11 &#x26;与&#x26;&#x26;的差异： 例12 这个题值得思考 在&#x26;&#x26;的判断中如果一边位假（0）那么程序就会停止向后面判断 。所以++b与d++并没有执行 在 || 的判断中如果一边为真，则停止继续向下判断。 （如：++a || b++ || c） 补充： 九 条件操作符 a > b ? a : b 十 逗号表达式 a,b,c,d,.....n 逗号表达式整体的值等于最后一个表达式的值 例13 思考题 另一种代码书写方式： 合理应用逗号表达式可以简化代码。 11 其他 1. [] 下标引用操作符 对于数组 arr[5] = {1,2,3,4,5} 我们一般的用法是： arr[0],arr[1],arr[2].....其中arr[0]就代表访问数组中第一个元素，arr[1]代表访问第二个以此类推 学习了指针之后我们知道： arr代表数组首元素的地址。 arr[0],arr[1],arr[2]...我们可以改写成： arr, (arr+1),*(arr+2)..... （ arr可以理解为 （arr+0）） 进一步思考： (arr+1)可以改写成(1+arr) 那么arr[1]可否写成1[arr]呢？答案是肯定的。 所以我们就有了一下结论： 1[arr] == arr[1] == *(arr+1) == *(1+arr) 事实上，无论哪一种写法，程序再最终编译的时候都会转化为：*( arr+1) 2. ( ) 函数调用操作符 例14.有参数调用 例15.无参数调用 c 3.访问一个结构的成员 . (结构体.成员名) -> （结构体指针 -> 成员名） 例16. 12 表达式求值 1. 隐式类型转换 C的整型算术运算总是至少以缺省整型类型的精度来进行的。 为了获得这个精度，表达式中的字符和短整型操作数在使用之前被转换为普通整型，这种转换称为 整型提升 。 整型提升的意义： 表达式的整型运算要在CPU的相应运算器件内执行，CPU内整型运算器(ALU)的操作数的字节长度 一般就是int的字节长度，同时也是CPU的通用寄存器的长度。 因此，即使两个char类型的相加，在CPU执行时实际上也要先转换为CPU内整型操作数的标准长 度。 通用CPU（general-purpose CPU）是难以直接实现两个8比特字节直接相加运算（虽然机器指令 中可能有这种字节相加指令）。所以，表达式中各种长度可能小于int长度的整型值，都必须先转 换为int或unsigned int，然后才能送入CPU去执行运算。 整型提升方法： 正数的整型高位补充0 负数补充1 例17.整型提升示例 注：char是有符号的 补充：字符类型的反码（1字节）及其表示的值 总结： char 的值范围是： -128 ~ 127 unsigned char 值的范围是： 0 ~ 255 例18.整型提升在程序中的证明 例子20.整型提升的再一次证明 2. 算数转换 如果某个操作符的各个操作数属于不同的类型，那么除非其中一个操作数的转换为另一个操作数的类 型，否则操作就无法进行。下面的层次体系称为寻常算术转换。 long double 8byte double 8byte float 4byte unsigned long int 4byte long int 4byte unsigned int 4byte int 4byte 如果某个操作数的类型在上面这个列表中排名较低，那么首先要转换为另外一个操作数的类型后执行运算 。 附：各类型变量在内存中占的字节 （32位） 3. 操作符属性 操作符的优先级 操作符的结合性 是否控制求值顺序"},{"title":"词法陷阱","slug":"c-traps/01-lexical-pitfalls","permalink":"/kb/posts/c-traps/01-lexical-pitfalls","category":"c-traps","description":"赋值与比较混淆、位运算与逻辑运算混淆、贪心法词法分析","date":"2026-06-16T00:00:00.000Z","content":"词法陷阱 词法陷阱 一 内容 0. = 不同于 == 当程序员本意是作比较运算时，却可能无意中误写成了赋值运算。 1.本意是检查 x 与 y 是否相等： 实际上是将 y 的值赋值给了 x ，然后再检查该值是否为 0 。 2.本意是跳过文件中的空白字符： 因为 ' ' 不等于 0 （ ' ' 的 ASCII 码值为 32），那么无论变量为何值，上述表达式求值的结果都为 1，因此循环将进行下去直到整个文件结束。 C 编译器发现形如 x = y 的表达式出现在选择语句，循环语句的条件判断部分时，会给出警告。当确实需要对变量进行赋值时，为了避免警告，我们应该这样处理： 如果将赋值写成了比较，也会造成混淆： 本例中，open 执行成功返回非零值，失败返回 -1。本意是将 open 函数的返回值存储在变量 filedesc 中，然后将其和 0 比较大小，判断 open 执行是否成功 。 == 运算符的结果只可能是 1 或 0，永远不会小于 0，所以 error() 将没有机会被调用。 1. &#x26; 和 | 不同于 &#x26;&#x26; 和 || 比较 i &#x26; j 和 i &#x26;&#x26; j ，只要 i 和 j 是 0 或 1 ，两个表达式的值是一样的（ | 和 || 同理。）。然而，一旦 i 和 j 的值为其他，两个表达式的值不会始终一致。 另一个区别是操作数带有自增自减的运算： i &#x26; j++ ， j 始终会自增；但是 i &#x26;&#x26; j++ 有时 j 不会自增。 2. 词法分析中的“贪心法” 当 C 的编译器读入一个字符 / 后跟着一个字符 * 时，那么编译器就必须做出判断：时将其作为两个符号对待，还是合起来作为一个符号对待。这类问题的规则： 每个符号应该包含尽可能多的符号 。 例如： a---b 和 (a--) - b 含义相同，而与 a - (--b) 含义不同。 又如：下面的语句本意是 x 除以 p 指向的值然后将结果赋值给 y 但是，实际上 /* 被编译器理解为一段注释的开始。 将上面的语句重写如下： 或者： 老版本的编译器允许使用 =+ 来代表现在 += 的含义，这种编译器会将： 理解为： 即为： 因此，如果程序员的原意为： 那么结果会让其大吃一惊。 再如： 在老版本的编译器会将其当作： 3. 整型常量 许多编译器会把 8 和 9 作为把八进制的数字处理，这种处理方式来源于八进制数的定义。例如：0195 的含义是 1x8^2 + 9x8 + 5x8^0 也就是 141（十进制）或 0215（八进制）。 ANSI C 标准中禁止这种用法。 4. 字符与字符串 单引号引起的一个字符实际上代表一个整数 。整数值对应于该字符在编译器采用的字符集中的序列值。因此，对于采用 ASCII 字符集的编译器而言， 'a' 的含义与 97 （十进制）严格一致。 用双引号引起的字符串，代表的确实一个指向无名数组起始字符的指针 。该数组被双引号之间的字符以及一个额外的二进制值为 0 的字符 \\0 初始化。 比如，下面的这个语句： 等价于： 整数型（一般为 16 或 32 位）的存储空间可以容纳多个字符（一般为 8 位），因此有的编译器允许在一个字符常量（以及字符串常量）中包含多个字符。也就是说：用 'yes' 代替 \"yes\" 不会被该编译器检测到。前者的含义大多数编译器理解为一个整数值，由 'y','e','s' 所代表的整数值按照特定编译器实现中的定义方式组合得到。 二 练习 练习 1 某些 C 编译器允许嵌套注释。请写一个测试程序，要求：无论编译器是否允许嵌套注释，该程序都能正常通过编译，但是两种情况下程序执行结果不同。 对于符号序列： 如果允许嵌套注释，上面的符号序列表示：一个单独的双引号 \" ，因为最后的注释符前出现的符号都会被当作注释的一部分。 如果不允许嵌套注释，上面的符号就表示一个字符串： \"*/\" Doug Mcllroy 发现了下面这个令人拍案叫绝的解法： 这个解法主要利用了编译器作词发分析时的“贪心法”规则。 如果编译器允许嵌套注释，则将上式解释为： 上式的值为 1 如果编译器不允许嵌套注释，则解释为： 也就是 0*1 ，值为 0 练习 2 a+++++b 的含义是什么？ 上式唯一有意义的解析方式就是： 可是，根据“贪心法”的规则，上式应该被解释为： 等价于： 但是 a++ 的值不能作为左值，因此编译器不会接受 a++ 作为后面 ++ 运算的操作数。 参考资料 ： 《C 缺陷与陷阱》"},{"title":"语法陷阱","slug":"c-traps/02-syntax-pitfalls","permalink":"/kb/posts/c-traps/02-syntax-pitfalls","category":"c-traps","description":"理解函数声明、运算符优先级陷阱、语句分号问题","date":"2026-06-16T00:00:00.000Z","content":"语法陷阱 语法“陷阱” 零 0. 理解函数声明 请思考下面语句的含义： 前面我们说过 C 语言的声明包含两个部分：类型和类似表达式的声明符。 最简单的声明符就是单个变量： 由于声明符和表达式的相似，我们可以在声明符中任意使用括号： 这个声明的含义是：当对 f 求值时， ((f)) 的类型为 float 类型，可以推知 f 也是浮点类型。 同样的，我们可以声明函数： 这个声明的含义是：表达式 ff() 求值结果是 float 类型，也就是返回 float 类型的函数。 类似的： 这个声明的含义是： *pf 是一个 float 类型的数，也就是说 pf 是指向 float 类型的指针。 以上的声明可以结合起来： *g() 和 (*h)() 是浮点表达式。因为 () （和 [] ）的优先级高于 * 。 *g() 也就是 *(g()) ：g 是一个函数，该函数返回一个指向浮点数的指针。同理，可以得到 h 是一个函数指针，h 所指向的函数返回值为浮点类型。 一旦我们知道如何声明一个给定类型的变量，那么该类型的类型转换符就很容易得到： 只需要把声明中的变量名和声明末尾的分号去掉，再用括号整体括起来 。 比如： 假定变量 fp 是一个函数指针，那么如何调用 fp 所指向的函数呢？调用方法如下： *fp 就是该指针所指向的函数。ANSI C 标准允许将上式简写为： 但是要记住这是一种简写方法。 注意： (*fp)() 和 *fp() 的含义完全不同，不要省略 *fp 两侧的分号。 现在我们声明一个返回值为 void 类型的函数指针： 如果我们现在要调用存储位置为 0 的子例程，我们是否可以这样写： 上式并不能生效，因为运算符 * 需要一个函数指针作为操作数。我们需要对 0 进行类型转换： 我们可以使用 typedef 来使表述更加清晰： 1. 运算符优先级问题 FLAG 是一个已经定义的常量，FLAG 是一个整数，该数的二进制表示中只有某一位是 1，其余的位都为 0 ，也就是 2 的某次幂。为了判断整数 flags 的某一位是否也是 1，并且将结果与 0 作比较，我们写出了上面 if 的判断表达式。 但是 != 的优先级高于 &#x26; ，上面的式子被解释为： 这显然不是我们想要的。 high 和 low 是两个 0 ~ 15 的数，r 是一个八位整数，且 r 的低 4 位与 low 一致，高 4 位与 high 一致，很自然想到： 但是，加法的优先级高于移位运算，本例相当于： 对于这种情况，有两种更正方法： 或利用移位运算的优先级高于逻辑运算： 下面我们说几个比较常见的运算符的用法： a.b.c 的含义是 (a.b).c 而不是 a.(b.c) 函数指针要写成： (*p)() ，如果写成了 *p() ，编译器会解释为： *(p()) *p++ 会解释为： *(p++) 而不是 (*p)++ 记住两点： 任何一个逻辑运算符的优先级低于任何一个关系运算符。 移位运算符的优先级比算数运算符要低，但是高于关系运算符。 赋值运算符结合方式从右到左，因此： 等价于： 关于涉及赋值运算时优先级的混淆： 复制一个文件到另一个文件中： 但是上式被解释为： 关系运算符的结果只有 0 或 1 两种可能。最后得到的文件副本中只包含了一组二进制为 1 的字节流。 2. 注意作为语句结束标志的分号 考虑下面的例子： 这与： 大不相同。 前面的例子相当于： 无论 x[i] 是否大于 big，赋值都会被执行。 如果不是多写了分号，而是遗漏了分号，一样会招致麻烦： 遗漏了 return 后的分号，这段程序仍然会顺利通过编译而不会报错，它等价于： 还有一种情形，也是有分号与没有分号实际效果相差极为不同。那就是当一个声明的结尾紧跟一个函数定义时，如果声明结尾的分号被省略，编译器可能会把声明的类型视作函数的返回值类型。考虑下例： 上面代码段的实际效果是声明函数 main 返回值是结构 logrec 类型。 如果分号没有被省略，函数 main 的返回值类型会缺省定义为 int 类型。 3. switch 语句 如果稍作改动： 假定 color 的值为 2，那么将会输出： 因为程序的控制流程在执行了第二个 printf 函数的调用后，会自然地顺序执行下去。第三个 printf 函数也会被调用。 switch 的这种特性，即使它的弱点，也是它的优势所在。 对于两个操作数的加减运算，我们可以将操作数变号来取代减法： 在这里，我们是有意省略 break 语句。 4. 函数调用 C 语言要求：在函数调用时，即使函数不带参数，也应该包含参数列表。如果，f 是一个函数： 是一个函数调用语句，而： 却是一个什么也不作的语句，f 表示函数的地址。 5. 悬挂 else 引发的问题 这个相信大家学习 C 的时候老师都会讲，在我的 【C 必知必会】系列教程中也有详细讲解，不懂可以去参考相关。 这里说一点，写 if 语句时，不要省略括号是一种可以学习的习惯。 参考资料 ： 《C 缺陷与陷阱》"},{"title":"语义陷阱","slug":"c-traps/03-semantic-pitfalls","permalink":"/kb/posts/c-traps/03-semantic-pitfalls","category":"c-traps","description":"指针与数组的语义混淆、数组越界、指针声明误区","date":"2026-06-16T00:00:00.000Z","content":"语义陷阱 语义陷阱 0. 指针与数组 C 语言中数组与指针这两个概念之间的联系密不可分。 关于数组： C 语言中只有一维数组，而且数组大小必须在编译期就作为一个常数确定下来。数组元素可以是任何类型的对象，也可以是另外一个数组。（C99 允许变长数组） 对于一个数组，我们只能够做两件事：确定该数组的大小，以及获得指向该数组下标为 0 的元素的指针。 任何一个数组下标运算都等同于一个对应的指针运算。 声明数组 声明了一个拥有 3 个整型元素的数组。 声明了一个拥有 17 个元素的数组，且每个元素都是一个结构。 声明了拥有 12 个数组类型的元素，其中每个元素都是拥有 31 个整型元素的数组。因此 sizeof(calendar) 的值是 12x31 与 sizeof(int) 的乘积。 关于指针 任何指针都是指向某种类型的变量。 表明 ip 是一个指向整型变量的指针。 我们可以将整型变量 i 的地址赋值给指针 ip ： 如果我们给 *ip 赋值，就可以改变 i 的取值： 数组与指针 如果一个指针指向的是数组中的一个元素，那么我们只要给这个指针加 1，就能够得到指向该数组中下一个元素的指针。减法同理。 如果两个指针指向的是同一个数组中的元素，那么两个指针相减是有意义的： 我们可以通过 q - p 得到 i 的值。 数组名被当作指向数组下标为 0 的元素的地址。 注意，我们没有写成： 这样的写法在 ANSI C 中是非法的，因为 &#x26;a 是一个指向数组的指针，而 p 是指向整型变量的指针，它们了类型并不匹配。 继续我们的讨论，现在 p 指向数组 a 中下标为 0 的元素，p + 1 指向下标为 1 的元素，以此类推。如果希望 p 指向下标为 1 的元素，可以这样写： 当然，也可以这样写： *a 是数组 a 中下标为 0 的元素的引用。同理， *(a + 1) 是数组中下标为 1 的元素的引用， *(a + i) 是数组中下标为 i 的元素的引用，简写为 a[i] 。 由于 a + i 和 i + a 的含义一致，因此 a[i] 和 i[a] 也具有相同的含义。但我们绝不推荐这种写法。 二维数组 请思考， calendar[4] 含义是什么？ calender[4] 是 calendar 数组第 5 个元素，是 calendar 数组 12 个拥有着 31 个整型元素的数组之一。 sizeof(calendar[4]) 大小为 31 与 sizeof(int) 的乘积。 这个语句使 p 指向了数组 calendar 下标为 0 的元素。 如果 calendar 是数组，我们可以： 上式等价于： 等价于： 下面我们再看： 这个语句是非法的。因为 calendar 是一个二维数组，即数组的数组，calendar 是一个指向数组的指针，而 p 是指向整型变量的指针。 我们需要声明一种指向数组的指针，经过上一章的讨论，我们不难得出： 这个语句的效果是：声明了 *ap 是一个拥有 31 个元素的数组，所以，ap 就是指向这样的数组的指针。因此，我们可以这样写： 这样 monthp 指向 calendar 数组的第一个元素，也就是 calendar 的 12 个拥有 31 个整型变量的数组类型的元素之一。 假定在新的一年开始时，我们需要清空 calendar 数组，用下标的形式可以很容易的做到： 上面的代码用指针应该如何表示？ 原书中的代码为： 1. 非数组的指针 假定我们两个这样的字符串 s 和 t，我们希望将这两个字符串连接成单个字符串 r ： 我们不确定 r 指向何处，而且 r 所指向的地址处不一定有内存空间可供容纳字符串。这一次，我们为 r 分配空间： C 语言强制要求我们必须声明数组大小为一个常量，因此我们不能保证 r 足够大。这时，我们可以利用库函数 malloc ： 这个例子还是错的，原因有 3 ： malloc 函数可能无法提供请求的内存 给 r 分配的内存在使用完后应该及时释放 strlen(s) 的值如果是 n ，那么字符串 s 的实际长度为 n + 1，因为，strlen 会忽略作为结束标志的空字符。所以，malloc 时，切记给字符串结尾的空字符留有空间。 修改： 2. 作为参数的数组声明 C 语言中，我们没有办法可以将一个数组作为函数参数直接传递。如果我们使用数组名作为参数，那么数组名会立刻被转换为指向该数组第 1 个元素的指针。例如： printf 函数调用等价于： 所以，C 语言中会自动的将作为参数的数组声明转换为相应的指针声明。也就是像这样的写法： 或： C 程序员经常错误的假设，在其他情况下也会有这种自动的转换。后面我们会说到： 和下面的语句有着天壤之别： 另一个常见的例子就是 main 函数的参数： 等价于： 需要注意的是，前一种写法强调 argv 是一个指向某数组元素为字符指针的起始元素的指针。因为这两种写法是等价的，所以可以任选一种最能清晰反应自己意图的写法。 3. 避免“举隅法” 指针的复制并不同时复制指针所指向的数据。 p 的值并不是字符串 \"xyz\" ，而是指向该字符串起始元素的指针。因此，如果我们执行下面的语句： 现在 p 和 q 是两个指向内存中同一地址的指针。如图： 因此，当我们执行完语句： q 所指向的内存存储的字符串是\"xYz\"，p 所指向的内存中存储的当然也是字符串\"xYz\" 。 注意 ：ANSI C 中禁止对 string literal （字符串字面量）作出修改。K&#x26;R 对这一行为的说明是：试图修改字符串常量的行为是未定义的。 4. 空指针并非空字符串 常数 0 转换而来的指针不等于任何有效的指针。 无论是用 0 还是符号 NULL，效果都是完全相同的。 空指针绝不能被解引用 。 下面的写法是合法的： 但是如果写成这样： 就是非法的了。因为库函数 strcmp 的实现中会查看它的指针参数所指向的内存中的内容。 如果 p 是一个空指针，即使 和 的行为也是未定义的。 5.边界计算与不对称边界 如果一个数组有 10 个元素，那么这个数组下标允许取值范围是什么呢？ 在 C 语言中，这个数组下标的范围是 0 ~ 9 。 栏杆错误 也称 差一错误 （off-by-one error）。 解决这种问题的通用原则： 首先考虑最简单情况下的特例，然后将得到的结果外推。 仔细计算边界，绝不掉以轻心。 不对称边界 解决差一错误的一个方法是使用不对称边界的思想。 比如，一个字符串中由下标为 16 到下标为 37 的字符元素组成的字串，如何表示这个范围？ 我们采用不对称边界： x >= 16 &#x26;&#x26; x &#x3C;38 而不是采用 x >= 16 &#x26;&#x26; x &#x3C;= 37 。这样，这个字串的长度明显就是 38 - 16，也就是 22 。 用 for 循环遍历一个大小为 10 的数组： 而非： 6. 求值顺序 C 语言中只有 4 个运算符（ &#x26;&#x26; ， || ， ?: ， , ）存在规定的求值顺序。 运算符 &#x26;&#x26; 和 || 首先对左操作数求值，只有在需要时才对右操作数求值。 运算符 ?: 有 3 个操作数：在 a ? b : c 中，首先对 a 求值，根据 a 的值再对操作数 b 或 操作数 c 求值。 逗号运算符从左向右一次求值。（求值然后丢弃再继续求值。） 运算符 &#x26;&#x26; 和 || 对于保证检查操作按照正确的顺序执行至关重要。例如在语句 中，就必须保证仅当 y 非 0 时才对 x / y 求值。 下面这种从数组 x 中复制前 n 个元素到数组 y 中的做法是不正确的： 问题出在哪里呢？上面的代码假设 y[i] 的地址在 i 的自增操作指向前被求值，这一点并没有任何保证。 同样的道理，下面的代码也是错误的： 应该使用这一种写法： 或： 7. 运算符 &#x26;&#x26; 和 || 与 运算符 &#x26; 和 | 按位运算 &#x26;，|，^ ，~ 对操作数的处理方式是将其视为一个二进制的位序列，分别对其每一位进行操作。 逻辑运算 &#x26;&#x26;，||，! 对操作数的处理方式是将其视为要么是“真” 要么是“假”。通常将 0 视为 假，非 0 视为 真。它们的结果只可能是 1 或 0 。 需要注意的是逻辑运算中的 &#x26;&#x26; 和 || 是有求值顺序的。 考虑下面的代码段，其作用是在表中查询一个特定的元素： 假定我们无意中用 &#x26; 替换了 &#x26;&#x26;： 这个循环也可能正常工作，但这仅仅是因为两个侥幸的原因： while 循环中的表达式 &#x26; 两侧都是比较运算，其结果只会是 1 或 0 。因此 x &#x26;&#x26; y 和 x &#x26; y 会具有相同的结果。然而，如果两个比较运算中的任意一个使用除 1 之外的非 0 的数表示“真”，那么这个循环就不能正常个工作了。 对于数组结尾后的下一个元素（实际上是不存在的），只要程序不去修改该元素的值，而仅仅读取它的值，一般情况下是不会有什么危害的。运算符 &#x26;&#x26; 和 &#x26; 不同，&#x26; 要求 两侧的操作数都必须被求值。因此，在后一个代码中，最后一次循环当 i 等于 tabsize 时，尽管 tab[i] 并不存在，程序依然会查看 tab[i] 的值。 8. 整数溢出 C 语言中存在两类整数算术运算，有符号运算与无符号运算。在无符号运算中，没有所谓“溢出”一说：所有无符号运算都是以 2 的 n 次方为模，这里 n 是结果中的位数。 如果算数运算符中的一个操作数是无符号整数一个是有符号整数，有符号整数会被转换为无符号整数。“溢出”同样不会发生。 但是当两个操作数都为有符号整数时，溢出就可能发生，而且“溢出”的结果是未定义的。 例如，假定 a 和 b 为连个非负整形变量，我们要检查 a + b 是否会“溢出”，一种想当然的方式： 这并不能正常运行。当 a + b 确实发生“溢出”时，所有关于结果如何的假设都是不可靠的。例如，有的计算机上，加法运算将设置内部寄存器为四种状态之一：正，负，零和溢出。在这种机器上，上面 if 语句的检测就会失效。 一种正确的方式为将 a 和 b 强转为无符号整数： 此处的 INT_MAX 是一个已定义常量，代表可能的最大整数值。ANSI C 标准在&#x3C;limits.h>中定义了 INT_MAX 。 不需要用到无符号整数运算的另一种可行的办法是： 9. 为 main 函数提供返回值 已在 【C 必知必会】系列详细讲解过。不再赘述。 参考资料 ： 《C 缺陷与陷阱》"},{"title":"链接","slug":"c-traps/04-linkage","permalink":"/kb/posts/c-traps/04-linkage","category":"c-traps","description":"连接器的工作原理、外部变量的链接与声明规则","date":"2026-06-16T00:00:00.000Z","content":"链接 连接 一 链接 0. 什么是连接器 C 语言的一个重要思想就是分别编译（separate compilation），即若干个源程序可以在不同的时候单独进行编译，然后在恰当的时候整合在一起。但是，连接器一般是与 C 编译器分离的，它不可能了解 C 语言的诸多细节。 **连接器的工作原理：**连接器的输入是一组目标模块和库文件。连接器的输出是一个载入摸块。连接器读入目标模块和库文件同时生成载入模块。对每个目标核块中的每个外部对象，链接器都要检查载入模块。查看是否有同名的外部对象。如果没有，连接器就将该外部对象添加到入模块中，如果有，连接器就要开始处理命名冲突。 **外部对象：**程序中的每个函数和每个外部变量，如果没有被声明为 static，就都是一个外部对象。 除了外部对象之外，目标模块中还可能包括了对其他模块中的外部对象的引用。例如一个调用了函数 printf 的 C 程序所生成的目标模块，就包括了一个对库函数 printf 的引用。可以推测得出，该引用指向的是一个位于某个库文件中的外部对象。在连接器生成载入模块的过程中，它必须同时记录这些外部对象的应用。当连接器读入一个目标模块时，它必须解析出这个目标模块中定义的所有外部对象的引用，并标记这些外部对象不再是未定义的。 1. 声明与定义 声明语句： 如果其位置出现在所有函数体之外，那么它就被称为外部对象 a 的定义。这个语句说明了 a 是一个外部整型变量，同时为 a 分配内存空间。它的初始值默认为 0 。 下面的声明语句： 不仅为 a 分配了内存空间，而且说明了在该内存中应该存储的值。 下面的声明语句： 并不是对 a 的定义。这个语句仍然说明了 a 是一个外部整型变量，但是 a 的存储空间是在程序的其他地方分配的。从连接器的角度来看，上面的声明是对 a 的引用，而不是定义。 每个外部对象都必须在某个地方进行定义 。因此，如果程序中包括了语句： 那么，这个程序就必须在别的某个地方包括语句： 这两个语句既可以是在同一个源文件中，也可以位于程序的不同源文件中。 严格的规则是， 每个外部变量都只能被定义一次 。 2. 命名冲突与 static 修饰符 两个具有相同名称的外部对象实际上代表的是同一个对象，即使编程者的本意并非如此，但系统却会如此处理。因此，如果在两个不同的源文件中都包括了定义： 那么，它或者表示程序错误(如果连接器禁止外部变量重复定义的话)，或者在两个源文件中共享 a 的同一个实例(无论两个源文件中的外部变量 a 是否应该共享)。 即使其中 a 的一个定义是出现在系统提供的库文件中，也仍然进行同样的处理。当然，一个设计良好的函数库不至于定义 a 作外部名称。但是，要了解函数库中定义的所有外部对象名称却也并非易事。类似于read 和 write 这样的名称不难猜到，但其他的名称就没有这么容易了。 static 修饰符是一个能够减少此类命名冲突的有用工具。例如，以下声明语句: 其含义与下面的语句相同 只不过，a 的作用域限制在一个源文件内，对于其他源文件，a是不可见的。因此，如果若干个函数需要共享一组外部对象，可以将这些函数放到一个源文件中，把它们需要用到的对象也都在同一一个源文件中以 static 修饰符声明。 static修饰符不仅适用于变量，也适用于函数。如果函数 f 需要调用另一个函数 g ，而且只有函数 f 需要调用函数 g ，我们可以把函数 g 和 f 放到同一个源文件中，并声明函数 g 为 static： 我们可以在多个源文件中定义同名的函数 g，只要所有的函数 g 都被定义为 static,或者仅仅只有其中一个函数 g 不是static 。因此，为了避免可能出现的命名冲突，如果一个函数仅仅被同一个源文件中的其他函数调用，我们就应该声明该函数为 static。 3. 形参，实参与返回值 如果任何一个函数在调用它的每个文件中，都在第一次被调用之前进行了声明或定义，那么就不会有任何与返回类型相关的麻烦。 比如一个调用 square 函数的程序： 要使这个程序能够运行，函数 square 必须要么在 main 函数之前进行定义： 要么在 main 函数前进行声明： 如果一个函数在被定义或声明之前被调用，那么它的返回类型就 默认为整型 。比如将上面的 main 函数放到一个独立的源文件中： main 函数假定函数 square 返回类型为整型，而函数 square 返回类型实际上是双精度类型，当他与 square 函数连接时就会得出错误的结果。 如果我们需要在两个不同的源文件中分别定义函数 main 和函数 square ，那么应该在 调用 square 函数的文件中声明 square 函数 。比如： ANSI C 允许程序员在声明时指定函数的参数类型（省略也是可以的，但是在函数定义时是不能省略参数类型的说明）。 像下面这样声明也是可以的： 默认实参提升 ： float 类型参数会转换为 double 类型 char 类型，short 类型参数会转换为 int 类型 对于声明： 如果在使用 isvowel 函数前没有这样声明，调用者将把传递给 isvowel 函数实参自动转换为 int 类型。 上面的程序不能正常运行，原因有两个： sqrt 函数本该接受一个双精度的值作为实参，而实际上被传递了一个整型 sqrt 函数的返回类型是双精度类型，但却没有这样声明。 一种更正方式是： 当然，最好的更正的方式是这样： 上面 sqrt 的实参已经修改为 2.0，然而即使仍然写成 2，在符合 ANSI C 的编译器上，这个程序也能确保实参会被转换为恰当的类型。 因为函数 printf 和函数 scanf 在不同情形下可以接受不同类型的参数，所以它们特别容易出错。这里有个值得注意的例子： 表面上，这个程序从标准输入设备读入 5 个数，在标准输出设备上写 5 个数：0 1 2 3 4 实际上，这个程序并不是一定得到上面的结果。例如，在某个编译器上，它的输出是：0 0 0 0 0 1 2 3 4 为什么呢?问题的关键在于，这里 c 被声明为 char 类型，而不是 int 类型。当程序要求 scanf 读入一个整数，应该传递给它一个指向整数的指针。而程序中scanf函数得到的却是一一个指向字符的指针，scanf 函数并不能分辨这种情况，它只是将这个指向字符的指针作为指向整数的指针而接受，并且在指针指向的位置存储一个整数。因为整数所占的存储空间要大于字符所占的存储空间，所以字符 c 附近的内存将被覆盖。 字符 c 附近的内存中存储的内容是由编译器决定的，本例中它存放的是整数 i 的低端部分。因此，每次读入一个数值到 c 时，都会将i的低端部分覆盖为 0 ,而 i 的高端部分本来就是 0 ,相当于 i 每次被重新设置为 0, 循环将一直进行。当到达文件的结束位置后，scanf 函数不再试图读入新的数值到 c 。这时，i 才可以正常地递增，最后终止循环。 4. 检查外部类型 假定我们有一个 C 程序，它由两个源文件组成。一个文件包含外部变量 n 的声明： 另一个文件中包含外部变量 n 的定义： 这是一个无效的 C 程序，因为同一个外部变量在两个文件中不能被声明为不同类型。然而编译器和连接器可能检查不出这种错误。 当这个程序运行时，究竟会发生什么情况呢？存在很多的可能情况： C 语言编译器足够“聪明”，能够检测到这类型冲突。编程者将会得到一条诊断消息，报告变量 n 在两个不同的文件中被给定了不同的类型。 读者使用的C语言实现对 int 类型的数值与 long 类型的数值在内部表示上是样的。尤其是在32位计算机上，一般都是如此处理。在这种情况下，程序很可能正常工作，就好像 n 在两个文件中都被声明为long (或int)类型一样。 本来错误的程序因为某种巧合却能够工作，这是一个很好的例子。 变量 n 的两个实例虽然要求的存储空间的大小不同，但是它们共享存储空间的方式却恰好能够满足这样的条件：赋给其中一个的值，对另一个也是有效的。这是有可能发生的。举例来说，如果连接器安排 int 类型的 n 与 long 类型的 n 的低端部分共享存储空间，这样给每个long类型的 n 赋值，恰好相当于把其低端部分赋给了 int 类型的 n。本来错误的程序因为某种巧合却能够作，这是一个比第 2 种情况更能说明问题的例子。 变量 n 的两个实例共享存储空间的方式，使得对其中一个赋值时，其效果相当于同时给另一个赋了 完全不同的值。在这种情况下，程序将不能正常工作。 因此，保证一个特定的名称的所有外部定义在每个目标模块中都有相同的类型，一般来说是程序员的责任。 考虑下面的例子，在一个文件中包含定义： 而在另一个文件中包含声明： 第一个例子中字符数组 filename 的内存布局大致如图： 第二个例子中字符指针 filename 的内存布局大致如图： 要更正本例，改法如下： 或： 现在我们回顾前面的程序： 这个程序在调用函数 sqrt 前没有对函数 sqrt 进行声明或定义。因此，这个程序完全等同于下面的程序： 这样的写法当然是错误的。 5. 头文件 有一个好方法可以避免大部分此类问题，这个方法只需要我们接受一个简单的规则:每个外部对象只在一个地方声明。这个声明的地方一般就在一个头文件中，需要用到该外部对象的所有模块都应该包括这个头文件。特别需要指出的是，定义该外部对象的模块也应该包括这个头文件。 例如，创建一个文件叫 file.h，它包含声明： 需要用到外部对象 filename 的每个 C 文件都应该加上这样的一个语句： 最后我们选择一个 C 源文件，在其中给出 filename 的初始值。 file.c 注意，源文件 file.c 中实际上包含了 filename 的两个声明，这一点只要把 include 语句展开就可以看出： 只要源文件 file.c 中 filename 的各个声明是一致的，而且这些声明中最多只有 1 个是 filename 的定义，这样写就是合法的。 二 练习 练习4-1. 假定一个程序在一个源文件中包含了声明: 而在另一个源文件中包含了: 又进一步假定，如果给long类型的 foo 赋一个较小的值，例如37,那么short类型的foo就同时获得了一个值37。我们能够对运行该程序的硬件作出什么样的推断?如果short类型的foo得到的值不是37而是0，我们又能够作出什么样的推断? 如果把值 37 赋给 long 型的 foo，相当于同时把值 37 也赋给了short型的foo,那么这意昧着 short 型的 foo，与 long 型的foo中包含了值37的有效位的部分，两者在内存中占用的是同一区域。long 型的 foo 的低位部分与 short 型的 foo 共享了相同的内存空间，因此我们的一个可能推论就是，运行该程序的硬件是一个低位优先(little-endian：小端) 的机器。 同样道理，如果在 long 型的 foo 中存储，了值 37，而 short 型的 foo 的值却是 0，我们所用的硬件可能是一个高位优先（big-endian：大端）的机器。 注：小端就是将数字的低位放在低地址；大端则相反。 练习4-2 .本章第 4节中讨论的错误程序，经过适当简化后如下所示: 在某些系统中，打印出的结果是 %g 请问这是为什么? 在某些 C 语言实现中，存在着两种不同版本的 printf 函数：其中一-种实现了用于表示浮点格式的项，如 %e、%f、%g 等;而另一种却没有实现这些浮点格式。库文件中同时提供了printf 函数的两种版本，这样的话，那些没有用到浮点运算的程序，就可以使用不提供浮点格式支持的版本，从而节省程序空间、减少程序大小。 在某些系统上，编程者必须显式地通知连接器是否用到了浮点运算。而另一些系统，则是通过编译器来告知连接器在程序中是否出现了浮点运算，以自动地作出决定。 上面的程序没有进行任何浮点运算！它既没有包含 math.h 头文件，也没有声明 sqr"},{"title":"库函数","slug":"c-traps/05-library-functions","permalink":"/kb/posts/c-traps/05-library-functions","category":"c-traps","description":"库函数的正确使用方式与返回值处理","date":"2026-06-16T00:00:00.000Z","content":"库函数 库函数 C语言中没有定义输入/输出语句,任何一个有用的 C 程序(起码必须接受零个或多个输入，生成一个或多个输出)都必须调用库函数来完成最基本的输入和输出操作。ANSI C 标准毫无疑问地意识到了这一点， 因而定义了一个包含大量标准库函数的集合。从理论上说，任何一个 C 语言实现都应该提供这些标准库函数。 有关库函数的使用，我们能给出的最好建议是尽量使用系统头文件。 一 库函数 1. 返回整数的 getchar 函数 getchar 函数在一般情况下返回的是标准输入文件中的下一个字符，当没有输入时返回EOF (一个在头文件stdio.h 中被定义的值，不同于任何一个字符)。这个程序乍一看似乎是把标准输入复制到标准输出，实则不然。 原因在于程序中的变量 c 被声明为 char 类型，而不是 int 类型。这意味着c无法容下所有可能的字符，特别是，可能无法容下 EOF 。 因此，最终结果存在两种可能。一种可能是，某些合法的输入字符在被“截断”后使得 c 的取值与 EOF 相同；另一种可能是, c 根本不可能取到EOF这个值。对于前一种情况，程序将在文件复制的中途终止；对于后一种情况，程序将陷入一个死循环。 实际上，还有可能存在第三种情况：程序表面上似乎能够正常工作，但完全是因为巧合。尽管函数 getchar 的返回结果在赋给 char 类型的变量 c 时会发生“截断”操作，尽管 while 语句中比较运算的操作数不是函数 getchar 的返回值，而是被“截断”的值 c,然而令人惊讶地是许多编译器对上述表达式的实现并不正确。这些编译器确实对函数 getchar 的返回值作了“截断”处理，并把低端字节部分赋给了变量c。但是，它们在比较表达式中并不是比较 c 与 EOF，而是比较 getchar 函数的返回值与 EOF ! 编译器如果采取的是这种做法，上面的例子程序看 上去就能够“正常”运行了。 2. 更新顺序文件 许多系统中的标准输入/输出库都允许程序打开一个文件,同时进行写入和读出的操作: 上面的例子代码打开了文件名由变量file 指定的文件，对于存取权限的设定表明程序希望对这个文件进行输入和输出操作。 编程者也许认为，程序一旦执行上述操作完毕，就可以自由地交错进行读出和写入的操作。遗憾的是，事实总难遂人所愿，为了保持与过去不能同时进行读写操作的程序的向下兼容性，一个输入操作不能随后直接紧跟一个输出操作，反之亦然。如果要同时进行输入和输出操作，必须在其中插入fseek 函数的调用。 下面的程序片段似乎更新了一个顺序文件中选定的记录:. 这段代码乍看上去毫无问题: &#x26;rec 在传入 fread 和fwrite 函数时被小心翼翼地转换为字符指针类型， sizeof(rec) 被转换为 长整型(fseek 函数要求第二个参数是 long 类型，因为 int类型的整数可能无法包含一个文件的大小；sizeof 返回一个unsigned 值，因此首先必须将其转换为有符号类型才有可能将其反号)。但是这段代码仍然可能运行失败，而且出错的方式非常难于察觉。 问题出在：如果一个记录需要被重新写入文件，也就是说，fwrite 函数得到执行，对这个文件执行的下一个操作将是循环开始的 fread 函数。因为在fwrite函数调用与fread函数调用之,间缺少了一个fseek函数调用，所以无法进行上述操作。解决的办法是把这段代码改写为: 第二个fseek函数虽然看上去什么也没做，但它改变了文件的状态，使得文件现在可以正常地进行读取了。 程序圆帮你理解 ： &#x26;rec 为何要强转成 char* 类型 ：这就要理解 fread 函数（ size_t fread ( void * ptr, size_t size, size_t count, FILE * stream ) ）：fread 函数的参数有四个，简单的来说就是：从 stream 中读 count 个 size 大小的元素到 ptr 指向的内存中。而 fread 内部在读取一个 size 大小的元素时会调用 size 次 fputc 函数，所以我猜测是每次用 fputc 函数读一个字节然后将该值赋给 ptr 指向的那个地址。既然 fputc 每次只能读一个，那也应该将 ptr 强转为 char* 类型。（但是函数原型是 void* 类型，会发生实参提升，转成 void* ，这又是个问题了）。 其实上面的程序可以简化为： 我们知道，读写之间需要调用一次 fseek，这就是为什么要在 fwrite 后调用 fseek 了。 3.缓冲输出 与内存分配 当一个程序生成输出时，是否有必要将输出立即展示给用户?这个问题的答案根据不同的程序而定。 程序输出有两种方式：一种是即时处理方式，另一种是先暂存起来，然后再大块写入的方式，前者往往造成较高的系统负担。因此，C语言实现通常都允许程序员进行实际的写操作之前控制产生的输出数据量。 这种控制能力一般是通过库函数 setbuf 实现的。如果buf是一个大小适当的字符数组，那么 语句将通知输入/输出库，所有写入到 stdout 的输出都应该使用 buf 作为输出缓冲区，直到 buf 缓冲区被填满或者程序员直接调用 flush (译注:对于由写操作打开的文件，调用 fflush 将导致输出缓冲区的内容被实际地写入该文件)，buf 缓冲区中的内容才实际写入到stdout 中。缓冲区的大小由系统头文件&#x3C;stdio.h>中的 BUFSIZ 定义。 程序圆帮你理解： setbuf 比较老，现在可以用 C99 引入的函数 setvbuf 下面的程序的作用是把标准输入的内容复制到标准输出中，演示了setbuf 库函数最显而易见的用法: 遗憾的是，这个程序是错误的，仅仅是因为一个细微的原因。程序中对库函数 setbuf 的调用，通知了输入输出库所有字符的标准输出应该首先缓存在 buf 中。要找到问题出自何处，我们不妨思考一下buf缓冲区最后一次被清空是在什么时候?答案是在 main 函数结束之后，作为程序交回控制给操作系统之前 C 运行时库所必须进行的清理工作的一部分。但是，在此之前 buf 字符数组已经被释放！ 要避免这种类型的错误有两种办法。第一种办法是让缓冲数组成为静态数组,即可以直接显式声明 buf 为静态: 也可以把 buf 声明完全移到 main 函数之外。 第二种办法是动态分配缓冲区，在程序中并不主动释放分配的缓冲区(译注:由于缓冲区是动态分配的，所以 main 函数结束时并不会释放该缓冲区，这样 C 运行时库进行清理工作时就不会发生缓冲区已释放的情况): 如果读者关心一些编程“小技巧”，也许会注意到这里其实并不需要检查 malloc 函数调用是否成功。如果 malloc 函数调用失败，将返回一个 NULL 指针。setbuf 函数的第二个参数取值可以为 NULL，此时标准输出不需要进行缓冲。这种情况下, 程序仍然能够工作，只不过速度较慢而已。 4. 使用errno检测错误 很多库函数，特别是那些与操作系统有关的，当执行失败时会通过一个名称为 errno 的外部变量，通知程序该函数调用失败。下面的代码利用这一 特性进行错误处理，似乎再清楚明白不过，然而却是错误的: 出错原因在于，在库函数调用没有失败的情况下，并没有强制要求库函数一定要设置 errno 为0，这样errno 的值就可能是前一个执行失败的库函数设置的值。 下面的代码作了更正，似乎能够工作，很可惜还是错误的: 库函数在调用成功时，既没有强制要求对 errno 清零，但同时也没有禁止设置 errno。既然库函数已经调用成功，为什么还有可能设置 errno 呢? 要理解这一点，我们不妨假想一下库函数 fopen 在调用时可能会发生什么情况。 当 fopen 函数被要求新建一个文件以供程序输出时，如果已经存在一个同名文件，fopen 函数将先删除它，然后新建一个文件。 这样，fopen 函数可能需要调用其他的库函数，以检测同名文件是否已经存在。(译注:假设用于检测文件的库函数在文件不存在时，会设置 errno 。那么，fopen 函数每次新建一个事先并不存在的文件时，即使没有任何程序错误发生，errmo 也仍然可能被设置。) 因此，在调用库函数时，我们应该首先检测作为错误指示的返回值，确定程序执行已经失败。然后，再检查 errno，来搞清楚出错原因: 5. 库函数 signal 关于 signal 函数使用需要避免的情况： 信号处理函数不应该调用复杂的库函数 （例如：malloc） 例如，假设malloc函数的执行过程被一个信号中断。 此时，malloc 函数用来跟踪可用内存的数据结构很可能只有部分被更新。如果 signal 处理函数再调用 malloc 函数，结果可能是 malloc 函数用到的数据结构完全崩溃，后果不堪设想! 从 siganl 函数中使用 longjup 退出 基于同样的原因，从 signal 处理函数中使用 longjmp 退出，通常情况下也是不安全的：因为信号可能发生在 malloc 或者其他库函数开始更新某个数据结构，却又没有最后完成的过程中。因此，signal 处理函数能够做的安全的事情，似乎就只有设置一个标志然后返回，期待以后主程序能够检查到这个标志，发现一个信号已经发生。 算数运算错误 然而，就算这样做也并不总是安全的。当一个算术运算错误(例如溢出或者零作除数)引发一个信号时，某些机器在signal 处理函数返回后还将重新执行失败的操作。而当这个算术运算重新执行时，我们并没有一个可移植的办法来改变操作数。这种情况下，最可能的结果就是马上又引发一个同样的信号。因此，对于算术运算错误，signal 处理函数的惟一安全、 可移植的操作就是打印一条出错消息，然后使用 longjmp 或 exit 立即退出程序。 由此，我们得到的结论是：信号非常复杂棘手，而且具有一些从本质上而言不可移植的特性。解决这个问题我们最好采取“守势”，让signal处理函数尽可能地简单，并将它们组织在一起。这样，当需要适应一个新系统时，我们可以很容易地进行修改。 练习 练习5-1 当一个程序异常终止时，程序输出的最后几行常常会去失，原因是什么?我们能够采取怎样的措施来解决这个问题? 一个异常终止的程序可能没有机会来清空其输出缓冲区。 解决方案就是在调试时强制不允许对输出进行缓冲。要做到这一点,不同的系统有不同的做法，这些做法虽然存在细微差别，但大致如下: 这个语句必须在任何输出被写入到 stdout(包括任何对 printf 函数的调用)之前执行。该语句最恰当的位置就是作为main函数的第一个语句。 练习5-2 下 面程序的作用是把它的输入复制到输出： 从这个程序中去掉 #include 语句，将导致程序不能通过编译，因为这时 EOF 是未定义的。假定我们手工定义了EOF (当然，这是一种不好的做法): 这个程序在许多系统中仍然能够运行，但是在某些系统运行起来却慢得多。这是为什么? 函数调用需要花费较长的程序执行时间，因此getchar经常被实现为宏。这个宏在stdio.h头文件中定义，因此如果一个程序没有包含 stdio.h 头文件，编译器对 getchar 的定义就一无所知。 在这种情况下，编译器会假定 getchar 是一个返回类型为整型的函数。 实际上，很多C语言实现在库文件中都包括有 getchar 函数，原因部分是预防编程者粗心大意，部分是为了方便那些需要得到 getchar 地址的编程者。因此，程序中忘记包含 stdio.h 头文件的效果就是，在所有 getchar 宏出现的地方，都getchar 函数调用来替换 getchar 宏。这个程序之所以运行变慢，就是因为函数调用所导致的开销增多。同样的依据也完全适用于putchar 。 参考资料 ： 《C 缺陷与陷阱》"},{"title":"预处理器","slug":"c-traps/06-preprocessor-pitfalls","permalink":"/kb/posts/c-traps/06-preprocessor-pitfalls","category":"c-traps","description":"预处理器的宏展开陷阱、副作用与最佳实践","date":"2026-06-16T00:00:00.000Z","content":"预处理器 预处理器 一 预处理器 在严格意义上的编译过程开始之前，C 语言预处理器首先对程序代码作了必要的转换处理。因此，我们运行的程序实际上并不是我们所写的程序。预处理器使得编程者可以简化某些工作，它的重要性可以由两个主要的原因说明(当然还有一些次要原因，此处就不赘述了)。 第一个原因是，我们也许会遇到这样的情况，需要将某个特定数量(例如，某个数据表的大小)在程序中出现的所有实例统统加以修改。我们希望能够通过在程序中，只改动一处数值，然后重新编译就可以实现。预处理器要做到这一点可以说是轻而易举，即使这个数值在程序中的很多地方出现。我们只需要将这个数值定义为一个显式常量(manifest constant)， 然后在程序中需要的地方使用这个常量即可。而且，预处理器还能够很容易地把所有常量定义都集中在一起， 这样要找到这些常量也非常容易。 第二个原因是，大多数 C 语言实现在函数调用时都会带来重大的系统开销。因此，我们也许希望有这样一种程序块， 它看上去像一个函数， 但却没有函数调用的开销。举例来说，getchar 和 putchar 经常被实现为宏，以避免在每次执行输入或者输出一个字符这样简单的操作时，都要调用相应的函数而造成系统效率的下降。 虽然宏非常有用，但如果程序员没有认识到宏只是对程序的文本起作用，那么他们很容易对宏的作用感到迷惑。也就是说，宏提供了一种对组成 C 程序的字符进行变换的方式，而并不作用于程序中的对象。因而，宏既可以使一段看上去完全不合语法的代码成为一个有效的 C 程序，也能使一段看 上去无害的代码成为一个可怕的怪物。 1. 不能忽视宏定义中的空格 一个函数如果不带参数，在调用时只需在函数名后加上--对括号即可加以调用了。而一个宏如果不带参数，则只需要使用宏名即可，括号无关紧要。只要宏已经定义过了，就不会带来什么问题:预处理器从宏定义中就可以知道宏调用时是否需要参数。与宏调用相比，宏定义显得有些“暗藏机关”。例如，下面的宏定义中f是否带了一个参数呢? 答案只可能有两种: 或者 f(x) 代表 ((x)-1) 或者 f 代表 (x) ((x)-1) 在上述宏定义中，第二个答案是正确的，因为在 f 和后面的 (x) 之间多了一个空格！所以，如果希望定义 f(x) 为 ((x)-1)，必须像下面这样写: 这一规则不适用于宏调用，而只对宏定义适用。因此，在上面完成宏定义后，f(3) 与 f (3) 求值后 都等于 2。 2. 宏并不是函数 因为宏从表面上看其行为与函数非常相似，程序员有时会禁不住把两者视为完全等同。因此，我们常常可以看到类似下面的写法: 或者： 请注意宏定义中出现的所有这些括号，它们的作用是预防引起与优先级有关的问题。例如，假设宏 abs 被定义成了这个样子: 让我们来看 abs(a-b) 求值后会得到怎样的结果。表达式 会被展开为 这里的子表达式 -a-b 相当于 (-a)-b，而不是我们期望的 -(a-b)，因此上式无疑会得到一个错误的结果。因此，我们最好在宏定义中把每个参数都用括号括起来。同样，整个结果表达式也应该用括号括起来，以防止当宏用于一个更大一些的表 达式中可能出现的问题。如果不这样， 展开后的结果为: 这个表达式很显然是错误的，我们期望得到的是 -a，而不是 -a+1！ abs 的正确定义应该是这样的： 这时，abs (a-b) 才会被正确地展开为: ((a-b)>0? (a-b):-(a-b)) ， 而 abs (a)+1 也会被正确地展开为: ((a)>0?(a):-(a))+1 即使宏定义中的各个参数与整个结果表达式都被括号括起来，也仍然还可能有其他问题存在，比如说，一个操作数如果在两处被用到，就会被求值两次。例如，在表达式 max(a,b )中，如果 a 大于 b，那么 a 将被求值两次：第一次是在 a 与 b 比较期间，第二次是在计算 max 应该得到的结果值时。 这种做法不但效率低下，而且可能是错误的:. 如果max是一个真正的函数，上面的代码可以正常工作；而如果max是一个宏，那么就不能正常工作。要看清楚这一点，我们首先初始化数组x中的一些元素: 然后考察在循环的第一次迭代时会发生什么。上面代码中的赋值语句将被扩展为: 首先，变量 biggest 将与 x[i++] 比较。因为 i 此时的值是 1, x[1] 的值是 3，而变量 biggest 此时的值是 x[0] 即 2,所以关系运算的结果为 false (假)。 这里，因为 i++ 的副作用，在比较后 i 递增为2。 因为关系运算的结果为 false (假),所以 x[i++] 的值将被赋给变量 biggest 。然而，经过 i++ 的递增运算后，i 此时的值是 2。所以，实际上赋给变量 biggest 的值是 x[2] ,即 1。这时，又因为 i++ 的副作用，i 的值成为 3 。 解决这类问题的一个办法是，确保宏 max 中的参数没有副作用: 另一个办法是让 max 作为函数而不是宏，或者直接编写比较两数取较大者的运算的代码: 下面是另外一个例子，其中因为混合了宏和递增运算的副作用，使代码显得岌岌可危。这个例子是宏 putc 的一个典型定义: 宏 putc 的第一个参数是将要写入文件的字符，第二个参数是一个指针,指向一个用于描述文件的内部数据结构。请注意这里的第一个参数 x，它极有可能是类似于 *z++ 这样的表达式。尽管 x 在宏 putc 的定义中两个不同的地方出现了两次， 但是因为这两次出现的地方是在运算符 : 的两侧，所以 x 只会被求值一次。 第二个参数 p 则恰恰相反，它代表将要写入字符的文件，总是会被求值两次。因为文件参数 p 一般不需要作递增递减之类有副作用的操作，所以这很少引起麻烦。不过, ANSI C 标准中还是提出了警告: putc 的第二个参数可能会被求值两次。 某些 C 语言实现对宏 putc 的定义也许不会像上面的定义那样小心翼翼，putc 的第一个参数很可能被不止一次求值，这样实现是可能的。编程者在给 putc 一个可能有副作用的参数时，应该考虑一下正在使用的 C 语言实现是否足够周密。 再举一个例子，考虑许多 C 库文件中都有的 toupper 函数，该函数的作用是将所有的小写字母转换为相应的大写字母，而其他的字符则保持原状。如果我们假定所有的小写字母和所有的大写字母在机器字符集中都是连续排列的(在大小写字母之间可能有一个固定的间隔)，那么我们可以这样实现toupper函数: 在大多数 C 语言实现中, toupper 的数在调用时造成的系统开销要大大多于函数体内的实际计算操作。因此，实现者很可能禁不住要把 toupper 实现为宏: 在许多情况下，这样做确实比把 toupper 实现为函数要快得多。然而，如果编程者试图这样使用 toupper (*p++) 则最后的结果会让所有人都大吃一惊! 使用宏的另一个危险是，宏展开可能产生非常庞大的表达式，占用的空间远远超过了编程者所期望的空间。例如，让我们再看宏max的定义: #define max(a,b) ((a)>(b)?(a):(b)) 假定我们需要使用上面定义的宏 max，来找到 a、b、 c、 d 四个数的最大者，最显而易见的写法是: max(a, max (b, max(c, d))) 上面的式子展开后就是: 确实，这个式子太长了!如果我们调整一下，使上式中操作数左右平衡: max (max(a,b) ,max(c,d) ) 现在这个式子展开后还是较长: . 其实，写成以下代码似乎更容易一些: 3. 宏并不是语句 编程者有时会试图定义宏的行为与语句类似，但这样做的实际困难往往令人吃惊! 举例来说，考虑一下assert 宏，它的参数是一个表达式，如果该表达式为 0，就使程序终止执行，并给出一条适当的出错消息。把 assert 作为宏来处理，这 样就使得我们可以在出错信息中包括有文件名和断言失败处的行号。也就是说， assert (x>y); 在x大于y时什么也不做，其他情况下则会终止程序。 下面是我们定义 assert 宏的第一次尝试: 因为考虑到宏 assert 的使用者会加上一个分号，所以在宏定义中并没有包括分号。 __FILE__ 和 __LINE__ 是内建于 C语言预处理器中的宏，它们会被扩展为所在文件的文件名和所处代码行的行号。 宏 assert 的这个定义，即使用在一个再明白直接不过的情形中，也会有一些难于察觉的错误: 上面的写法似乎很合理，但是它展开之后就是这个样子: 把上面的代码作适当的缩排处理，我们就能够看清它实际的流程结构与我们期望的结构有怎样的区别: 读者也许会想到，在宏 assert 的定义中用大括号把宏体整个给“括”起来，就能避免这样的问题产生: 然而，这样做又带来了一个新的问题。我们上面提到的例子展开后就成了: 在else之前的分号是一个语法错误。要解决这个问题，一个办法是对 assert 的调用后面都不再跟一个分号，但这样的用法显得有些“怪异”: 宏 assert 的正确定义很不直观，编程者很难想到这个定义不是类似于一个语句，而是类似一个表达式 这个定义实际上利用了 || 运算符对两侧的操作数依次顺序求值的性质。 如果 e 为真，|| 后半部分语句不会被执行。 4. 宏不是类型定义 宏的类型定义： 宏的这种用法有一个优点——可移植性，得到了所有 C 编译器的支持。 但是我们最好还是使用类型定义： 这个语句定义了 FOOTYPE 为一个新的类型，与 struct foo 完全等效。 这两种命名类型的方式似乎都差不多，但是使用typedef的方式要更加通用一些。例如，考虑下面的代码: 从上面两个定义来看，T1 和 T2 从概念上完全符同，都是指向结构foo的指针。但是，当我们试图用它们来声明多个变量时，问题就来了: 第一个声明被扩展为: struct foo * a，b; 这个语句中 a 被定义为一个指向结构的指针，而 b 却被定义为一个结构(而不是指针)。 第二个声明则不同，它定义了 a 和 b 都是指向结构的指针，因为这里 T2 的行为完全与一个真实的类型相同。 二 练习 1. 练习6-1 请使用宏来实现max的一个版本，其中max的参数都是整数，要求在宏 max 的定义中这些整型参数只被求值一次。 max宏的每个参数的值都有可能使用两次: 一次是在两个参数作比较时；一次是在把它作为结果返回时。因此，我们有必要把每个参数存储在一个临时变量中。 遗憾的是,我们没有直接的办法可以在一个 C表达式的内部声明一个临时变量。因此，如果我们要在一个表达式中使用 max宏，那么我们就必须在其他地方声明这些临时变量，比如说可以在宏定义之后，但不是将这些变量作为宏定义的一部分进行声明。如果 max 宏用于不止一个程序文件，我们应该把这些临时变量声明为 static, 以避免命名冲突。不妨假定，这些定义将出现在某个头文件中: 只要对max宏不是嵌套调用，上面的定义都能正常工作;在 max 宏嵌套调用的情况下，我们不可能做到让它正常工作。 2. 练习6-2 本章第1节中提到的“表达式” (x) ((x)-1) 能否成为一个合法的C表达式? 一种可能是，如果 x 是类型名，例如 x 被这样定义: typedef int x; 在这种情况下， (x) ((x)-1) 等价于 (int) ((int)-1) 这个式子的含义是把常数 -1 转换为 int 类型两次。我们也可以通过预处理指令来定义 x 为一种类型，以达到同样的效果: #define x int 另一种可能是当 x 为函数指针时。回忆一下，如果某个上下文中本应需要函数而实际上却用了函数指针，那么该指针所指向的函数将会自动地被取得并替换这个函数指针。因此，本题中的表达式可以被解释为调用 x 所指向的函数，这个函数的参数是 (x)-1 。为了保证 (x)-1 是一个合法的表达式，x 必须实际地指向一个函数指针数组中的某个元素。 x 的完整类型是"},{"title":"可移植性缺陷","slug":"c-traps/07-portability-issues","permalink":"/kb/posts/c-traps/07-portability-issues","category":"c-traps","description":"C语言程序的可移植性问题：字节序、类型大小与未定义行为","date":"2026-06-16T00:00:00.000Z","content":"可移植性缺陷 可移植性缺陷 了解更多有关可移植可以参考《How to Write Portable Software in C》（Prentice-Hall）。 本章主要讨论几个常见的错误来源，重点放在语言属性上，而非函数库属性上。 一 可移植性缺陷 1. 应对 C 语言标准变更 这种语言标准的变更使得 C 程序的编写者面临一个两难境地:程序中是否应该用到新的特性呢? 如果使用它们,程序无疑更加容易编写，而且不大容易出错,但是那样做也有代价，那就是这些程序在较早的编译器上将无法工作。 本书的 4.4节讨论了一个这类例子：函数原型的概念。让我们回想一下 4.4 节中提到的 square 函数: 如果这样写，这个函数在很多编译器上都不能通过编译。如果我们按照旧风格来重写这个函数,因为 ANSI 标准为了保持和以前的用法兼容也允许这种形式，这就增强了它的可移植性: 这种可移植性的获得当然也付出了代价。为了与旧用法保持一致， 我们必须在调用了 square 函数的程序中作如下声明: 函数声明中略去参数类型的说明，这在 ANSI C 标准中也是合法的。因为这样的声明并没有对参数类型做出任何说明，就意味着如果在函数调用时传入了错误类型的参数，函数调用就会不声不响地失败: 函数square的声明中并没有对参数类型做出说明，因此在编译 main 函数时，编译器无法得知函数 square 的参数类型应该是 double，而不是 int 。这样，程序打印出的将是一堆 “垃圾信息”。要检测这类问题，有一个办法就是使用 lint 程序，前提是编程者的 C 语言实现提供了这一工具。 如果上面的程序被写成了这样: 这里，3 会被自动转换为double类型。 另种改写的方式是，在这个程序中显式地给函数 square 传入一个 double 类型的参数: 这样做程序就能得到正确的结果。即使是对于那些不允许在函数声明中包括参数类型的旧编译器，第二种写法也仍然能够使程序照常工作。 许多有关可移植性的决策都有类似的特点。一个程序员是否应该使用某个新的或特定的特性?使用该特性也许能给编程带来巨大的方便，但代价却是使程序失去了一部分潜在用户。 2. 标识符名称的限制 某些 C 语言实现把一个标识符中出现的所有字符都作为有效字符处理，而另一些 C实 现却会自动地截断一个长标识符名称的尾部。连接器也会对它们能够处理的名称强加限制，例如外部名称中只允许使用大写字母。C实现者在面对这样的限制时，一个合理的选择就是强制所有的外部名称必须是大写。事实上， ANSI C标准所能保证的只是，C实现必须能够区别出前 6 个字符不同的外部名称。而且，这个定义中并没有区分大写字母与其对应的小写字母 。 因为这个原因，为了保证程序的可移植性，谨慎地选择外部标识符的名称是重要的。比方说，两个函数的名称分别为 print_fields 与 print_float 这样的命名方式就不恰当；同理， 使用 State 与 STATE 这样的命名方式也不明智。 考虑以下函数: 上面的例子程序演示了一个确保检测到内存耗尽的异常情况的简单办法。编程者的想法是，在程序中应该调用 malloc 函数分配内存的地方，改为调用 Malloc 函数。如果 malloc 函数调用失败，则 panic 函数将被调用，panic 函数终止程序,并打印出一条恰当的出错消息。这样，客户程序就不必在每次调用malloc函数时都要进行检查。 然而，考虑一下如果这个函数的编译环境是不区分外部名称大小写的 C 语言实现，将会发生怎样的情况呢? 此时，函数malloc 与Malloc 实际上是等同的。也就是说，库函数 malloc将被上面的 Malloc 函数等效替换。当在 Malloc 函数中调用库函数 malloc 时,实际上调用的却是 Malloc 函数自身!当然,尽管函数 Malloc 在那些区分大小写的C语言实现上仍然能够正常工作,但在这种情况下结果却是:程序在第一次试图分配内存时对 Malloc 函数的调用将引起一系列的递归调用， 而这些递归调用又不存在一个返回点，最后引发灾难性的后果! 3. 整数的大小 C语言中为编程者提供了3种不同长度的整数: short 型、 int 型和 long 型，C 语言中的字符行为方式与小整数相似。C语言的定义中对各种不同类型整数的相对长度作了一些规定: 3种类型的整数其长度是非递减的 。也就是说，short 型整数容纳的值肯定能够被 int 型整数容纳，int 型整数容纳的值也肯定能够被 long 型整数容纳。对于一个特定的 C 语言实现来说，并不需要实际支持 3 种不同长度的整数，但可能不会让 short 型整数大于 int 型整数，而 int 型整数大于 long 型整数。 一个普通(int 类型)整数足够大以容纳任何数组下标。 字符长度由硬件特性决定。 ANSI 标准要求 long 型整数的长度至少应该是 32 位,而 short 型和 int 型整数的长度至少应该是 16 位。因为大多数机器中字符长度是8位，对这些机器而言最方便的整数长度是 16 位和 32 位，因此所有早期的C编译器也都能够满足这些限制条件。 程序员当然可以用一个 int 型整数来表示一个数据表格的大小或者数组的下标。但如果一个变量需要存放可能是千万数量级的数值，又该如何呢? 要定义这样一个变量，可移植性最好的办法就是声明该变量为 long 型，但在这种情况下我们定义一个“新的”类型无疑更为清晰: 而且，程序员可以用这个新类型来声明所有此类变量，最坏的情形也不过是我们只需要改动类型定义，所有这些变量的类型就自动变为正确的了。 4. 字符是有符号整数还是无符号整数 现代大多数计算机都支持 8 位字符，因此大多数现代 C 编译器都把字符实现为 8 位整数。然而，并非所有的编译器都按照同样的方式来解释这些8 位数值。 只有在我们需要把一个字符值转换为一个较大的整数时，这个问题才变得重要起来。而在其他情况下，结果都是已定义的:多余的位将被简单地“丢弃”。编译器在转换 char 类型到 int 类型时，需要做出选择:应该将字符作为有符号数还是应该无符号数处理?如果是前一种情况，编译器在将 char 类型的数扩展到 int 类型时，应该同时复制符号位:而如果是后一种情况，编译器只需在多余的位上直接填充 0 即可。 如果一个字符的最高位是1，编译器是将该字符当作有符号数，还是无符号数呢?对于任何一个需要处理该字符的程序员来说，上述选择的结果非常重要。它决定着一个 8 位字符的取值范围是从 -128 到 127 ,还是从 0 到 255。而这一点，又反过来影响到程序员对哈希表或转换表等的设计方式。 如果编程者关注一个最高位是1的字符其数值究竟是正还是负，可以将这个字符声明为无符号字符(unsigned char)。这样，无论是什么编译器，在将该字符转换为整数时都只需将多余的位填充为 0 即可。而如果声明为一般的字符变量，那么在某些编译器上可能会作为有符号数处理，在另一些编译器上又会作为无符号数处理。 与此相关的一个常见错误认识是：如果 c 是一个字符变量，使用 (unsigned)c 就可得到与 c 等价的无符号整数。这是会失败的，因为在将字符 c 转换为无符号整数时，c 将首先被转换为 int 型整数，而此时可能得到非预期的结果。正确的方式是使用语句 (unsigned char)c ，因为一个 unsigned char 类型的字符在转换为无符号整数时无需首先转换为int型整数，而是直接进行转换。 5. 移位运算符 使用移位运算符的程序员经常对这样两个问题感到困惑： 在向右移位时，空出的位是由 0 填充，还是由符号位的副本填充? 移位计数(即移位操作的位数)允许的取值范围是什么? 第一个问题的答案很简单，但有时却是与具体的 C 语言实现有关。如果被移位的对象是无符号数,那么空出的位将被0填充。如果被移位的对象是有符号数，那么 C 语言实现既可以用 0 填充空出的位,也可以用符号位的副本填充空出的位。编程者如果关注向右移位时空出的位，那么可以将操作的变量声明为无符号类型，那么空出的位都会被设置为0。 第二个问题的答案同样也很简单：如果被移位的对象长度是 n 位，那么移位计数必须大于或等于 0，而严格小于 n。因此，不可能做到在单次操作中将某个数值中的所有位都移出。为什么要有这个限制呢?因为只要加上了这个限制条件, 我们就能够在硬件上高效地实现移位运算。举例来说，如果一个 int 型整数是 32 位，n 是一个 int 型整数，那么 n&#x3C;&#x3C;31 和 n&#x3C;&#x3C;0 这样写是合法的，而 n&#x3C;&#x3C;32 和 n&#x3C;&#x3C;-1 这样写是非法的。 需要注意的是，即使 C 实现将符号位复制到空出的位中，有符号整数的向右移位运算也并不等同于除以 2 的某次幂。要证明这一点， 让我们考虑 (-1)>>1 ，这个操作的结果一般不可 能为 0,但是 (-1)/2 在大多数 C 实现上求值结果都是 0。这意味着以除法运算来代替移位运算，将可能导致程序运行速度大大减慢。举例而言，如果已知下面表达式中的 low+high 为非负，那么 与下式 完全等效，而且前者的执行速度也要快得多。 6. 内存位置 0 null 指针并不指向任何对象。因此，除非是用于赋值或比较运算，出于其他任何目的使用 null 指针都是非法的。例如，如果 p 或 q 是一个 null 指针，那么 strcmp(p, q) 的值就是未定义的。 在这种情况下究竟会得到什么结果呢?不同的编译器有不同的结果。某些 C 语言实现对内存位置 0 强加了硬件级的读保护，在其上工作的程序如果错误使用了一个 null 指针，将立即终止执行。其他一些 C 语言实现对内存位置 0 只允许读，不允许写。在这种情况下，一个 null 指针似乎指向的是某个字符串，但其内容通常不过是一堆“垃圾信息”。还有一些 C 语言实现对内存位置0既允许读，也允许写。在这种实现上面工作的程序如果错误使用了一个 null 指针，则很可能覆盖了操作系统的部分内容，造成彻底的灾难! 严格说来，这并非一个可移植性问题：在所有的C程序中，误用 null 指针的效果都是未定义的。然而，这样的程序有可能在某个 C 语言实现上“似乎”能够工作，只有当该程序转移到另一台机器上运行时才会暴露出问题来。要检查出这类问题的最简单办法就是，把程序移到不允许读取内存位置 0 的机器上运行。下面的程序将揭示出某个 C 语言实现是如何处理内存地址 0 的： 在禁止读取内存地址 0 的机器上，这个程序将会执行失败。在其他机器上，这个程序将会以 10 进制的格式打印出内存位置 0 中存储的字符内容。 7. 除法运算时发生的截断 假定我们让a除以b，商为q，余数为r : 这里，不妨假定 b 大于 0。我们希望a、b、q、r之间维持怎样的关系呢? 最重要的一点，我们希望 q * b + r == a ， 因为这是定义余数的关系。 如果我们改变 a 的正负号，我们希望这会改变 q 的符号，但这不会改变 q 的绝对值。 当 b > 0 时，我们希望保证 r >= 0 且 r &#x3C; b 。例如，如果余数用于哈希表的索引。确保它是一个有效的索引值很重要。 这三条性质是我们认为整数除法和余数操作所应该具备的。很不幸的是，它们不可能同时成立。 考虑一个简单的例子: 3 / 2， 商为 1，余数也为 1。此时，第 1 条性质得到了满足。(-3)/2 的值应该是多少呢?如果要满足第 2 条性质，答案应该是 -1,但如果是这样，余数就必定是 -1，这样第 3 条性质就无法满足了。如果我们首先满足第 3 条性质,即余数是 1，这种情况下根据第 1 条性质则商是 -2，那么第 2 条性质又无法满足了。 因此，C 语言或者其他语言在实现整数除法截断运算时，必须放弃上述三条原则中的至少一条。 大多数程"},{"title":"建议","slug":"c-traps/08-recommendations","permalink":"/kb/posts/c-traps/08-recommendations","category":"c-traps","description":"C语言编程的防御性编码建议与常见错误防范","date":"2026-06-16T00:00:00.000Z","content":"建议 建议 1. 不要说服自己相信“皇帝的新装” 有的错误极具伪装性和欺骗性。比如，第一章原来的例子是这样写的: 如上，这个例子在 C 语言中是非法的。因为赋值运算符 = 的优先级比 while 子句中其他运算符的优先级都要低，因此上例可以这样解释: 当然，这是非法的: (c == '\\t' || c) 不能出现在赋值运算的左侧。 2. 直截了当地表明意图 当你编写代码的本意是希望表达某个意思，但这些代码有可能被误解为另一种意思时，请使用括号或者其他方式让你的意图尽可能清楚明了。这样做不仅有助于你日后重读程序时能够更好地理解自己的用意，也方便了其他程序员日后维护你的代码。 有时候我们还应该预料哪些错误有可能出现，在代码的编写方式上做到事先预防，一旦错误真正发生能够马上捕获。例如，有的程序员把常量放在判断相等的比较表达式的左侧。换言之，不是按照习惯的写法: 而是写作: 这样，如果程序员不小心把比较运算符 == 写成了赋值运算符 = ，编译器将会捕获到这种错误，并给出一条编译器诊断信息: 上面的代码试图给字符常量 '\\t' 赋值，因而是非法的。 3. 考查最简单的特例 无论是构思程序的工作方式，还是测试程序的工作情况，这一原则都是适用的。当部分输入数据为空或者只有一个元素时，很多程序都会执行失败，其实这些情况应该是一早就应该考虑到的。这一原则还适用于程序的设计。在设计程序时，我们可以首先考虑一组输入数据全为空的情形，从最简单的特例获得启发。 4. 使用不对称边界 本系列第三章节关于如何表示取值范围的讨论，值得一读再读。C 语言中数组下标取值从 0 开始，各种计数错误的产生与这一点或多或少有关系。 我们一旦理解了这个事实，处理这些计数错误就变得不那么困难了。 5. 注意潜伏在暗处的Bug 各种C语言实现之间，都存在着或多或少的细微差别。我们应该坚持只使用C语言中众所周知的部分，而避免使用那些“生僻”的语言特性。这样做，我们能够很方便地将程序移植到一个新的机器或编译器，而且“遭遇”到编译器Bug的可能性也会大大降低。 6. 防御性编程 对程序用户和编译器的假设不要太多！ 如果 C 编译器能够捕获到更多的编程错误，这当然不错。不幸的是，因为几方面的原因，要做到这一点很困难。最重要的原因也许是历史因素：长期以来，人们惯于用C语言来完成以前用汇编语言做的工作。因此，许多C程序中总有这样的部分，刻意去做那些严格说来在 C 语言所允许范围以外的工作。最明显的例子就是类似操作系统的东西。这样，一个C编译器要做到严格检测程序中的各种错误，就要对程序中本意是可移植的部分做到严格检测，同时对程序中那些需要完成与特定机器相关工作的部分网开一面。 另一个原因是，某些类型的错误从本质上说是难于检测的。考虑下面的函数: 这个函数是合法还是非法?离开一定的上下文，我们当然不可能知道答案。 如果像下面的代码一样调用这个函数: 这当然是合法的，但如果这样来调用 set 函数: 上面的代码就是非法的了。ANSI C 标准允许程序得到数组尾端出界的第一个位置的地址，因此上面的后一个代码段从它本身来说并没有什么错误。C编译器要想捕获到这样的错误，就必须非常地“聪明”。 参考资料 ： 《C 缺陷与陷阱》"}]