1947 年,第一台通用电子计算机 ENIAC 诞生,重达 30 吨,占满了一整个房间。它没有键盘、没有鼠标、没有显示器——程序员通过插拔电缆和拨动开关来"编程"。1972 年,贝尔实验室的 Dennis Ritchie 在 B 语言的基础上设计出了 C 语言,然后用它重写了 Unix 操作系统的核心代码。从此,人类和计算机之间的"对话方式"发生了革命性的变化。
今天,你手机里装的 App、你浏览器访问的网站后端、你电脑上的操作系统内核——它们的底层,大概率都跑着 C 语言的代码。这就是为什么我说 C 语言是绕不过去的:它不仅是一门语言,更是通往计算机底层世界的第一把钥匙。
你可能会问:"C 语言都五十多岁了,学它还有用吗?" 如果你去查 TIOBE 编程语言排行榜(一个追踪语言流行度的权威榜单),会发现 C 语言常年稳居前三。2024 年的榜单里,C 和 C++ 依然牢牢占据前五——一门半世纪前的语言还能有这样的生命力,在编程语言的历史上是绝无仅有的。而且 C 语言的语法并不复杂,C89 标准只有 32 个关键字,相比 Java 庞大的类库或 Python 上百个内置函数,C 语言可以说是"小而美"的典范。但正因为它"小",很多东西需要你自己管理(比如内存),这也是它相对难学的地方。
这篇文章是 C 语言系列的第一篇。我不会假设你有任何编程基础——我们从最基础的"C 语言是什么"讲起,一路走到写出第一个程序、理解 main 函数和 printf、玩转 ASCII 编码和字符串、搞懂转义字符和注释。你需要的只是一台电脑和一份好奇心。
不过在开始之前,有两点需要提前说清楚。第一,C 语言是编译型语言。这意味着你写的 .c 源代码文件不能直接运行——它需要经过一个叫"编译器"的工具翻译成机器能懂的二进制文件(在 Windows 上是 .exe),然后才能执行。这也是为什么你需要安装 Visual Studio 2022 这样的 IDE(集成开发环境)——它把编辑器、编译器、调试器打包在一起,你写完代码按一个快捷键就能跑起来。与 Python 和 JavaScript 不同,你不能写完一行就立即看到结果——你得先编译,再运行。这听起来麻烦了一点,但你很快就会习惯。第二,编译器会严格检查你的代码。哪怕少写一个分号,它都会毫不客气地告诉你"语法错误"。初学者容易被满屏的红色报错吓到,但这些报错其实是在帮你——每一个错误信息都在告诉你哪里出了问题。学会读报错,是编程入门最重要的能力之一。
C 语言是什么?
人和人交流用的是自然语言——汉语、英语、日语。那人和计算机交流呢?用计算机语言。目前已知的计算机语言有上千种,C 语言就是其中之一,此外还有 C++、Java、Go、Python 等等。
C 语言最初的定位非常明确:它是为了开发 Unix 操作系统而发明的。1972 年,贝尔实验室的 Dennis Ritchie(丹尼斯·里奇)在 B 语言的基础上设计了 C 语言,然后用它重写了 Unix 的核心代码。也就是说,C 语言从诞生的第一天起,就和操作系统紧密绑定。今天,Linux 的内核仍然是 C 写的,Windows 的核心组件也是 C 和 C++ 写的,甚至 Python 的解释器(CPython)底层也是用 C 实现的。
这段历史值得多说两句,因为它解释了 C 语言很多"怪脾气"的由来。在 C 之前,贝尔实验室的 Ken Thompson 已经开发了 B 语言,但 B 语言没有类型系统,写起来很痛苦。Ritchie 想在 B 的基础上加类型、加结构体,于是诞生了 C。C 语言的设计目标从来不是"让初学者好上手",而是"让程序员能高效地写系统软件"。所以它贴近硬件、信任程序员、几乎不做任何运行时检查——数组越界它不管,内存泄漏它不查,全靠程序员自觉。这个设计哲学贯穿了 C 语言的一生,也决定了你学习它的方式:学 C 不能只学语法,还要学"内存里到底发生了什么"。
C 语言还有一个独特之处:它产生了一套"家族语言"。C++ 是 C 的超集(在 C 基础上加了面向对象),Java 的语法大量借鉴了 C,C# 的名字里就带着 C,Objective-C 是给 C 加了面向对象层用来开发 macOS 和 iOS 应用。甚至连 Go 语言和 Rust,都能看出 C 语言语法的影子。所以说"学会 C 语言,其他语言就通了一大半"——这话虽然夸张,但方向没错。
另外澄清一个常见的误会:很多人以为 C 语言"过时了"。实际上,它依然是当今世界上使用最广泛的语言之一。全球市值最高的公司里,有大量核心代码是 C 写的:Linux 内核、Windows 内核、数据库(MySQL、SQLite)、Web 服务器(Nginx)、浏览器引擎(V8、WebKit)、嵌入式系统(汽车、路由器、智能家电)……C 语言像是数字世界的"混凝土",你看不见它,但整个建筑都靠它撑着。所以学 C 不是为了"怀旧",而是为了拿到一张能看穿计算机底层原理的地图。
C 语言发展的关键节点
| 年份 | 事件 |
|---|---|
| 1969~1972 | Ken Thompson 开发 B 语言,Dennis Ritchie 在其基础上设计 C |
| 1973 | 用 C 重写 Unix,C 语言开始流行 |
| 1978 | Kernighan & Ritchie 出版《The C Programming Language》,即"K&R C" |
| 1989 | ANSI 发布 C89 标准(也就是 C90,ISO 采用后叫 C90) |
| 1999 | ISO 发布 C99 标准:引入 long long、_Bool、变长数组、// 注释等 |
| 2011 | ISO 发布 C11 标准:引入 _Generic、匿名结构体、多线程支持等 |
| 2018 | 发布 C17 标准:主要是缺陷修复,没有大的新特性 |
| 2023 | 发布 C23 标准:引入 bool 关键字、nullptr、[[attributes]] 等 |
看到这个时间线你会发现一个规律:C 标准的更新节奏很慢,十几年才出一个新版本。这也意味着——几十年前写的 C 代码,今天照样能编译运行。向后兼容是 C 语言最宝贵的财富之一,也是它长寿的秘密。
编译器的选择
前面说过 C 语言是编译型语言,所以你需要一个编译器。市面上常见的 C 语言编译器有三种:
- MSVC:微软出品的编译器,集成在 Visual Studio 中,是 Windows 平台的主流选择。
- GCC:GNU 编译器套件,Linux/Unix 世界的标配,开源免费,几乎无处不在。
- Clang:基于 LLVM 的编译器,苹果 XCode 中集成,macOS 和 iOS 开发的首选,以编译速度快和错误提示友好著称。
对于初学者,我推荐 Visual Studio 2022 社区版。理由很直接:免费、集成度高(编辑器+编译器+调试器一个工具搞定)、界面默认是中文的、企业中使用也相当普遍——你学的东西和工作能直接衔接。当然它也有缺点:安装包大,占空间多。但对于学习来说,这完全值得。
在 VS2022 中创建第一个 C 项目
光看文字没用,跟着做一遍才有感觉。打开 VS2022 后:
- 点击"创建新项目";
- 在模板列表里选择"空项目"(Empty Project)——不要选 C++ 的模板,空项目最干净,C 和 C++ 都能写;
- 给项目起个名字(比如
HelloC),选择存放位置,点"创建"; - 在右侧"解决方案资源管理器"里,右键"源文件"文件夹 →"添加"→"新建项";
- 选择"C++ 文件(.cpp)"——注意!VS 里没有单独的 C 文件模板,但你只要把文件后缀名改成
.c,编译器就会按 C 语言来处理; - 把文件命名为
test.c,点"添加"。
从这一步开始,你就有了一个能写 C 代码的工程。今后所有代码示例,都在这个环境里运行。
一个完整程序从源码到运行的四步
一个完整的 C 程序从源文件到可执行文件,要经过四个步骤:预处理(处理 #include、#define 等指令)→ 编译(翻译成汇编代码)→ 汇编(翻译成机器指令,生成 .obj 文件)→ 链接(将多个 .obj 和库文件合并成 .exe)。
展开讲一下每一步发生了什么:
- 预处理:这一阶段以
#开头的指令(叫"预处理指令")被处理。比如#include <stdio.h>会被替换成stdio.h文件的实际内容,#define定义的宏会被原地替换成对应的文本。你可以在 VS 里对着一个文件右键→"属性"→"C/C++"→"命令行"加/P参数编译,就能看到预处理后的.i文件——那会是一个比原文件长几十倍的文件,因为头文件内容全被复制进来了。 - 编译:把预处理后的代码翻译成汇编语言。汇编是给人看的一类低级语言,每条指令(如
mov、add)对应一条机器指令。 - 汇编:把汇编代码翻译成机器指令(0 和 1),生成目标文件
.obj(Windows 上)。此时每个.c文件都变成了独立的.obj文件。 - 链接:把多个
.obj文件和你调用的库函数实现(比如printf的实现藏在某个.lib文件里)合并成一个完整的.exe可执行文件。
如果你学过 Java 或 Python,它们的运行机制完全不同——Java 编译成字节码后在虚拟机上运行,Python 是解释型语言一行行执行。而 C 语言编译完就是纯机器码,直接跑在 CPU 上,没有任何中间层。这也是 C 语言性能极高的根本原因。
源文件和头文件——一个工程的两类零件
在 VS 里写代码,第一步是创建"项目"(Project),然后往项目里添加两种文件:以 .c 结尾的源文件和以 .h 结尾的头文件。源文件里放代码实现,头文件里放声明——你前面看到的 stdio.h 就是一个头文件。一个工程通常由多个 .c 文件组成,每个 .c 文件会被编译器单独处理成一个 .obj 目标文件,最后链接器把所有 .obj 文件和库文件合并成一个 .exe 可执行程序。
这里先建立一个认知:源文件与源文件之间是"各自为政"的。a.c 里定义的函数,b.c 默认情况下是"看不见"的——除非用 extern 声明或包含头文件。这个机制以后讲函数时会详细展开,现在你只需要知道:每个 .c 文件是一个独立的编译单元,而 .h 头文件是各个编译单元之间共享信息的"公告栏"。
第一个 C 语言程序
废话不多说,直接上代码。这是你作为 C 语言程序员写的第一个程序:
#include <stdio.h> /* 引入标准输入输出头文件 */
int main() /* 程序入口:main 函数 */
{
printf("hello C\n"); /* 调用 printf 函数在屏幕上打印 */
return 0; /* main 函数返回 0,表示程序正常结束 */
}让我们逐行理解。#include <stdio.h> 是一条预处理指令,告诉编译器"在编译之前,先把 stdio.h 这个文件的内容复制到这里"。stdio 是 Standard Input/Output(标准输入输出)的缩写,printf 这个函数的声明就在这个头文件里。如果你没有这一行,编译器就不认识 printf,会报"未定义的标识符"错误。
int main() 是程序的入口函数。每一个 C 程序,不管有多少行代码、有多少个文件,都必须从 main 函数开始执行——没有 main 函数,程序就不知道该从哪跑起。int 表示 main 函数执行完毕后会返回一个整数,这就是为什么最后有那行 return 0;——0 是 C 语言程序员之间的约定:返回 0 表示"一切正常"。
printf("hello C\n") 调用标准库函数 printf,把双引号里的文本输出到屏幕上。\n 是换行符——打印完 "hello C" 之后光标会移到下一行。如果你去掉 \n,下一次 printf 的输出会紧跟在后面。
在 VS2022 中,按 Ctrl+F5 就可以编译并运行这个程序。你会看到控制台窗口弹出,显示 "hello C",然后"按任意键继续"。
第一次写代码的常见错误清单
第一次写代码,新手最容易犯的几个错误:
| 错误 | 原因 | 正确写法 |
|---|---|---|
mian | 把 main 拼错 | main |
int main 漏括号 | 函数必须带括号 | int main() |
中文分号 ; | 中英文标点混用 | 英文分号 ; |
忘了 #include <stdio.h> | 没有声明 printf | 加上这一行 |
| 漏掉语句末尾的分号 | 每条语句必须以分号结尾 | return 0; |
| 括号不配对 | 左括号右括号数量不等 | 检查 {} () 配对 |
用了中文双引号 "" | 字符串必须用英文引号 | "hello C" |
这些都是必经之路,踩过几次就记住了。记住一个排查技巧:报错信息里往往带着行号,比如 test.c(5): error C2146,意思是"第 5 行出错"。双击报错信息,VS 会自动把光标跳到出错的那一行——先看行号,再看具体内容,多数错误一眼就能看出来。
main 函数——程序的入口
main 函数是整个 C 程序的起点。关于 main,有三个关键点必须记住:
第一,main 是程序的入口。 程序从 main 的第一行开始执行,到 main 的最后一行结束。这是 C 语言标准明确规定的。
第二,main 函数有且仅有一个。 哪怕你的项目里有几十个 .c 文件,也只能有一个 main 函数。因为程序的入口只能有一个——如果入口有两个,计算机就不知道该从哪里开始了。
第三,int main() 和 return 0; 是配对的。 这告诉操作系统"程序正常结束了"。如果你返回非 0 值,通常表示出现了某种错误。虽然在某些编译器下 void main() 也能通过,但按照 C 语言标准,main 应该返回 int。
/* 错误示例:一个项目里不能有两个 main 函数 */
/* file1.c */
int main()
{
return 0;
}
/* file2.c */
int main() /* 错误!第二个 main 会导致链接失败 */
{
return 0;
}上面这个错误发生在链接阶段——编译阶段每个文件都能各自通过,但链接器发现有两个 main 符号,直接报错 LNK2005: main already defined。这个报错还提醒我们:编译和链接是两步,错误可能发生在任何一步。
main 的几种标准写法
C 语言标准里,main 有两种规范写法:
/* 写法一:不接受命令行参数(最常用) */
int main(void)
{
return 0;
}
/* 写法二:接受命令行参数 */
int main(int argc, char* argv[])
{
return 0;
}argc(argument count)是命令行参数的个数,argv(argument vector)是参数本身组成的数组。比如你在命令行里输入 myprog.exe hello world,那么 argc == 3,argv[0] 是 "myprog.exe",argv[1] 是 "hello",argv[2] 是 "world"。这在你以后写命令行工具时会用到,现在先混个脸熟。
注意一个细节:空括号 () 和 (void) 不完全一样。在函数定义里,int main() 和 int main(void) 都能编译;但在函数声明(没有函数体的那种)里,int fun(); 表示"参数个数未指定",int fun(void); 才表示"明确没有参数"。写 (void) 是最严谨的。
return 0 到底返回给谁?
你可能好奇:return 0; 返回给谁?答案是操作系统。程序是操作系统(比如 Windows)启动的,程序结束时把 main 的返回值交还给操作系统。在命令行里你可以用 echo %ERRORLEVEL%(Windows)查看程序的退出码。约定俗成:0 表示成功,非 0 表示失败。比如一个程序执行到一半发现文件打不开,可以 return 1; 表示"出错了",其他程序或脚本可以根据这个退出码判断它有没有成功。
printf 和库函数——不要重复造轮子
printf 是你在 C 语言中用到的第一个库函数。它的功能很简单:在屏幕上打印信息。但它背后代表的概念很重要——库函数。
什么是库函数?想象一下,如果每次你想在屏幕上打印点东西,都要自己写几百行底层代码去操作显卡、控制显示器像素……那编程效率就太低了。C 语言标准制定者们也想到了这一点,所以他们规定了一组常用函数,让编译器厂商去实现,程序员直接拿来用就行。这些函数组成的集合就叫标准库,里面的函数就叫库函数。
printf 除了能打印纯文本,还能通过占位符来打印各种类型的数据。占位符以 % 开头,后面跟一个字母表示类型:
#include <stdio.h>
int main()
{
int n = 100; /* 定义一个整型变量 n,值为 100 */
printf("%d\n", n); /* %d 是整数占位符,输出:100 */
char ch = 'Q'; /* 定义一个字符变量 ch,值为 'Q' */
printf("%c\n", ch); /* %c 是字符占位符,输出:Q */
double pi = 3.14159; /* 定义一个双精度浮点变量 pi */
printf("%lf\n", pi); /* %lf 是 double 占位符,输出:3.141590 */
/* 多个占位符可以同时使用 */
printf("n=%d, ch=%c, pi=%lf\n", n, ch, pi);
/* 输出:n=100, ch=Q, pi=3.141590 */
return 0;
}%d 对应整数(int),%c 对应字符(char),%lf 对应双精度浮点数(double)。这些占位符就像一个模板里的空位——printf 执行时,用后面参数的值依次去填。有几个占位符,就得有几个对应的参数,数量和类型都得对上。每个库函数都声明在特定的头文件里——printf 在 stdio.h 中,所以用之前别忘了 #include <stdio.h>。
占位符和参数不匹配会发生什么?
如果占位符和参数类型对不上,程序不会在编译时报错,但运行结果是错的,甚至直接崩溃。这是 C 语言"信任程序员"的又一体现——它默认你知道自己在干什么,不帮你检查。
#include <stdio.h>
int main()
{
double pi = 3.14159;
/* 错误:%d 期望 int,却传了 double */
printf("%d\n", pi);
/* 输出一个巨大的乱码数字(把 double 的内存按 int 解读) */
return 0;
}printf 之所以没法帮你检查,是因为它接收参数的方式是"可变参数"——它只知道格式字符串里写了 %d,就去内存里按 int 的大小读取,至于那个位置到底放的是什么类型,它无从得知。这也是为什么占位符必须和参数类型严格匹配。
C 语言的关键字——被"预定"的名字
C 语言里有 32 个保留字(C89 标准),这些单词已经被 C 语言"预定"了,你不能拿它们来做变量名或函数名:
auto break case char const continue
default do double else enum extern
float for goto if int long
register return short signed sizeof static
struct switch typedef union unsigned void
volatile while
C99 又加入了 inline、restrict、_Bool、_Complex、_Imaginary 等关键字。
这些关键字每一个都有特殊含义。比如 int 表示整数类型,if 表示条件判断,return 表示函数返回。现在你不用全部记住,随着学习深入自然就熟了。但有一点从第一天起就要记住:你的变量名、函数名不能和关键字重名。
/* 错误示例:用关键字做变量名 */
int int = 10; /* 错误!int 是关键字 */
int if = 20; /* 错误!if 是关键字 */
/* 正确做法 */
int number = 10; /* 用有意义的英文单词做变量名 */用关键字做变量名,编译器会直接报"语法错误",因为编译器根本分不清你这个 int 到底是类型还是变量名。
C 语言中除了关键字,你自己取的名字(变量名、函数名、数组名等)统称为标识符。标识符只能由字母、数字和下划线组成,且不能以数字开头。my_var 合法,123abc 不合法,my-var 也不合法(减号不是下划线)。
标识符命名规范
| 写法 | 示例 | 是否合法 | 说明 |
|---|---|---|---|
| 小写字母+下划线 | my_var | ✅ 合法且推荐 | C 社区主流风格 |
| 驼峰式 | myVar | ✅ 合法 | 风格也常见 |
| 全部大写 | MAX_SIZE | ✅ 合法 | 通常用于宏常量 |
| 数字开头 | 1abc | ❌ 非法 | 编译器报错 |
| 含连字符 | my-var | ❌ 非法 | 减号会被当成运算符 |
| 含空格 | my var | ❌ 非法 | 空格分隔词法单元 |
| 关键字 | int | ❌ 非法 | 关键字被预定 |
命名还有一个软性建议:名字要能"自我解释"。age、score、total_count 一看就知道含义;a1、x2、tmp 用多了连自己都看不懂。好的命名是写代码的第一课,不是可选项。
字符和 ASCII 编码——计算机如何储存文字
你有没有想过,你在键盘上敲的 'a'、'Q'、'@' 这些符号,计算机是怎么存储的?
计算机里所有数据最终都是二进制的 0 和 1。为了让计算机能处理文字,人们制定了一套编码规则:给每个字符分配一个唯一的数字编号。这套规则就是 ASCII 编码(American Standard Code for Information Interchange,美国信息交换标准代码)。
在 C 语言中,字符用单引号括起来,比如 'a'、'B'、'@'。每个字符在内存中存储的实际上是它对应的 ASCII 码值(一个 0~127 之间的整数)。
你不需要记住整个 ASCII 表,但有几组数据非常有用:
'A'到'Z'的 ASCII 值是 65 到 90'a'到'z'的 ASCII 值是 97 到 122- 大小写字母的差值是 32(
'a' - 'A' = 32) - 数字字符
'0'到'9'的 ASCII 值是 48 到 57 - 换行符
\n的 ASCII 值是 10 - ASCII 值 0 到 31 的字符是不可打印字符(控制字符)
为什么大小写字母恰好差 32?因为 32 是 2 的 5 次方,二进制是 00100000——正好是"第 5 位"。把大写字母的二进制第 5 位从 0 变成 1,就得到小写字母(或反过来)。所以 C 语言里大小写转换可以用位运算一行搞定,这个技巧以后讲位操作时会遇到。
部分 ASCII 码表速览
| 十进制 | 字符 | 说明 |
|---|---|---|
| 0 | \0 | 空字符(NULL),字符串结束标志 |
| 10 | \n | 换行 |
| 13 | \r | 回车 |
| 32 | (空格) | 第一个可打印字符 |
| 48~57 | '0'~'9' | 数字字符 |
| 65~90 | 'A'~'Z' | 大写字母 |
| 97~122 | 'a'~'z' | 小写字母 |
| 127 | DEL | 删除键 |
你可以用代码直接验证这些数字:
#include <stdio.h>
int main()
{
/* 用字符本身打印 */
printf("%c\n", 'Q'); /* 输出:Q */
/* 用 ASCII 码值打印同样的字符 */
printf("%c\n", 81); /* 输出:Q,因为 'Q' 的 ASCII 值是 81 */
/* 验证大小写差 32 */
printf("%d\n", 'a' - 'A'); /* 输出:32 */
/* 小写转大写的常用技巧 */
char lower = 'g';
char upper = lower - 32; /* 'g'(103) - 32 = 71 = 'G' */
printf("%c -> %c\n", lower, upper); /* 输出:g -> G */
return 0;
}注意这段代码里的一个有趣现象:同一个 printf 用 %c 打印时,81 被当作字符 Q 处理;用 %d 打印时,字符 'a' 被转换为它的 ASCII 码值 97。这是因为字符在 C 语言底层就是整数——char 类型本质上是一个 1 字节的整数类型。
这个"字符即整数"的特性带来两个实用技巧:第一,判断字符是不是数字,可以直接比较 '0' <= ch && ch <= '9',因为数字字符的编码是连续的;第二,把数字字符转成数值,直接 ch - '0' 就行——'5' - '0' = 53 - 48 = 5。
下面这段代码可以打印出所有可显示字符(ASCII 32 到 127),运行看看效果:
#include <stdio.h>
int main()
{
int i = 0;
/* 从 ASCII 32(空格)开始,到 127 结束 */
for (i = 32; i <= 127; i++)
{
printf("%c ", i); /* 打印字符本身 */
if (i % 16 == 15) /* 每 16 个字符换一行 */
printf("\n");
}
return 0;
}这段代码里出现了 for 循环——你现在不需要完全理解它,我们后面会详细讲。你只需要知道它的作用是让变量 i 从 32 数到 127,每数一个数就把对应的 ASCII 字符打印出来。
中文字符为什么不在这张表里?
你可能会想到:那汉字怎么办?ASCII 只有 128 个编码,显然装不下几万个汉字。答案是需要更大的编码体系:GB2312、GBK、UTF-8、UTF-16……这些统称为"字符集"。一个汉字在 UTF-8 里通常占 3 个字节,在 GBK 里占 2 个字节。C 语言里处理英文字符用 char(1 字节)就够了,但处理中文需要用 char 数组(比如 char str[] = "你好";),数组里存的是一串字节。这个坑以后处理中文输入输出时会遇到,现在先有个印象:char 一个字符 = 一个英文字符,≠ 一个汉字。
字符串与 \0——看不见的结束标志
在 C 语言中,用双引号括起来的一串字符就是字符串,比如 "hello"、"C language"。打印字符串用 %s 占位符:
#include <stdio.h>
int main()
{
printf("%s\n", "hello C"); /* 用 %s 打印字符串 */
printf("hello C\n"); /* 也可以直接打印 */
return 0;
}这里有个非常重要但初学者经常忽略的概念:C 语言的字符串末尾隐藏着一个 \0 字符。
\0 是一个特殊的转义字符(ASCII 值为 0),表示字符串的结束。当你写 "abc" 时,实际存储在内存中的是四个字符:'a'、'b'、'c'、'\0'。printf 打印字符串时,是一个字符一个字符往后打印的——遇到 \0 就停下来。strlen 计算字符串长度时也是靠 \0 判断终点的。
如果你不信,做个实验就清楚了:
#include <stdio.h>
int main()
{
/* arr1 是字符数组,手动放了 3 个字符,没有 \0 */
char arr1[] = {'a', 'b', 'c'};
/* arr2 用字符串初始化,编译器自动在末尾加 \0 */
char arr2[] = "abc";
printf("arr1: %s\n", arr1); /* 可能输出 abc 后面跟乱码 */
printf("arr2: %s\n", arr2); /* 正常输出 abc */
return 0;
}运行这段代码你会发现,arr1 的打印不正常——打印完 abc 之后还会出现一些乱码。这是因为 arr1 没有 \0 作为结束标志,printf 不知道在哪里停下来,就继续打印内存中后面的随机值。而 arr2 因为有 \0,所以打印正常。
解决办法是手动加上 '\0':
#include <stdio.h>
int main()
{
char arr1[] = {'a', 'b', 'c', '\0'}; /* 手动加上 \0 */
char arr2[] = "abc";
printf("arr1: %s\n", arr1); /* 正常输出 abc */
printf("arr2: %s\n", arr2); /* 正常输出 abc */
/* \0 对字符串的截断效果 */
printf("%s\n", "abc\0def"); /* 只输出 abc,\0 后的内容不显示 */
return 0;
}为什么 C 语言要设计 \0 而不是记录长度?
你可能想问:为什么 C 语言不用"长度"来管理字符串,非要放一个看不见的 \0?这又是"历史+性能"的选择。用长度前缀(像 Pascal 那样,第一个字节存长度)的缺点是:长度有上限、改字符串时要同步改长度、遍历时多一次访问。而 \0 方案实现极简、遍历极快——读到 0 就停。代价就是:你必须自己保证 \0 存在。这是 C 语言"信任程序员"哲学的又一次体现。后来很多安全漏洞(比如著名的缓冲区溢出攻击)都源于字符串 \0 处理不当,但那是代价,也是 C 语言的宿命。
这个 \0 的概念非常重要。后面你学数组、指针、字符串处理函数的时候,很多奇怪的 bug 都是因为忘记或错误处理了 \0 导致的——要么忘了留 \0 的位置导致越界,要么不小心覆盖了 \0 导致字符串"变长"。养成"字符串末尾必有 \0"的思维习惯,能帮你避开无数坑。
转义字符——改变字符的含义
在前面的代码里你已经看到了 \n、\0,它们都是转义字符。转义字符的作用就是字面意思:转变原来字符的含义。一个普通的 n 加上 \ 就变成了换行,一个普通的 0 加上 \ 就变成了字符串结束标记。
C 语言中常用的转义字符:
| 转义字符 | 含义 |
|---|---|
\? | 问号本身(防止连写问号时被解析成三字母词) |
\' | 单引号 |
\" | 双引号 |
\\ | 反斜杠本身 |
\a | 响铃(发出警报声) |
\b | 退格(光标回退一个字符,但不删除字符) |
\f | 换页(光标移到下一页,现代系统上行为接近 \v) |
\n | 换行 |
\r | 回车(光标移到行首) |
\t | 水平制表符(跳到下一个水平制表位) |
\v | 垂直制表符(光标移到下一行的同一列) |
\0 | 空字符(null),字符串结束标志 |
\ddd | 1~3 位八进制数表示的字符 |
\xdd | 2 位十六进制数表示的字符 |
来看看实际用法:
#include <stdio.h>
int main()
{
/* 打印单引号 */
printf("%c\n", '\''); /* 输出:' */
/* 打印双引号 */
printf("%s\n", "\""); /* 输出:" */
/* 打印文件路径中的反斜杠 */
printf("c:\\test\\code\\test.c\n"); /* 输出:c:\test\code\test.c */
/* 八进制转义:\130 = 十进制 88 = ASCII 字符 'X' */
printf("%c\n", '\130'); /* 输出:X */
/* 十六进制转义:\x30 = 十进制 48 = ASCII 字符 '0' */
printf("%c\n", '\x30'); /* 输出:0 */
return 0;
}注意 \ddd 和 \xdd 这两种形式——它们让你可以用 ASCII 码值的八进制或十六进制形式来表示任意字符。比如 \101(八进制的 101 = 十进制的 65)打印出来就是字符 'A'。在处理一些不可见字符或特殊控制字符时,这种表达方式很有用。
\n 与 \r 的区别:Windows 换行的历史包袱
\n 是把光标移到下一行,\r 是把光标移到本行开头。这两者的组合就是"回车换行"。Windows 文本文件的换行其实是两个字符 \r\n(回车+换行),而 Linux/Unix 只有一个 \n,macOS 早期用 \r。历史原因是老式电传打字机:先 \r 让打印头回到行首,再 \n 换到下一行。所以你在 Windows 上写文本文件、用二进制模式读取时,经常要处理这个差异——很多跨平台程序出现"多了个 ^M 符号"的怪问题,根源就在这里。不过 printf 里的 \n 在 Windows 控制台会被自动转换成 \r\n,你一般感觉不到。
路径和转义——那个著名的坑
另外要注意一个容易踩的坑:如果你要打印文件路径,比如 c:\test\code\test.c,你不能直接在代码里写 "c:\test\code\test.c"——因为 \t 会被解释为制表符,而 \c 不是合法的转义字符(编译器通常会发出警告,具体行为由实现决定)。正确写法是把每个反斜杠写成 \\,第一个 \ 用来转义第二个 \,让它"做回自己"。
/* 错误写法:\t 被当成制表符,\c 是非法转义(编译器警告) */
/* printf("c:\test\code\test.c\n"); */
/* 正确写法:每个反斜杠都要写成 \\ */
printf("c:\\test\\code\\test.c\n");
/* 输出:c:\test\code\test.c */转义字符的常见误区
'\n'和"\n"的区别:前者是一个字符(换行符),占 1 字节;后者是一个字符串,占 2 字节(\n加\0)。printf("%c", '\n')和printf("%s", "\n")效果一样,但类型不同,不能混用占位符。- 转义字符在字符串里生效,但在注释里就是普通字符——
/* \n */不会真的换行,注释只是给人看的。 \b退格并不删除字符,只是让光标回退——在很多终端上再打印会把原字符"盖住",看起来像删除了,其实是覆盖。
语句和注释
C 语言的代码由一条条语句组成,每条语句以分号 ; 结尾。C 语言中的语句分为五类:
空语句:只有一个分号,什么都不做。
; /* 这是一条空语句,什么都不做 */空语句看起来没用,但它有一个真实场景:for 循环需要一条语句当循环体,但你什么也不想做的时候:
/* 把字符串后面的空白全部跳过 */
/* 循环体是空语句:什么都不做,只是让 i 一直往后走 */
while (str[i] == ' ')
i++;表达式语句:表达式后面加分号。
int a = 20;
int b = 0;
b = a + 5; /* 这是一条表达式语句 */函数调用语句:调用函数时加分号。
printf("hello\n"); /* 函数调用语句 */
int ret = Add(2, 3); /* 函数调用语句 */复合语句:用大括号 {} 括起来的一组语句,也叫代码块。
{
int a = 10;
printf("%d\n", a);
} /* 这是一个复合语句 */复合语句还有一个重要特性:它内部定义的变量只在该代码块内有效(作用域)。以后学变量时你会深刻体会到这一点。
控制语句:用于控制程序执行流程,共有九种:
- 条件判断:
if、switch - 循环执行:
while、do while、for - 转向语句:
break、goto、continue、return
你会发现 C 语言的语法非常精简——只有五类语句、九种控制语句、三十二个关键字。但正是这些简单的积木,搭出了操作系统、数据库、浏览器引擎这些复杂的软件。这就是"小而强"的典范:语法简单到可以全记住,表达力强到能写任何程序。
最后说注释。注释是写给程序员自己或同事看的文字,编译器会完全忽略它们。C 语言有两种注释方式:
/* */ 形式(C89 就有):可以跨行,但不能嵌套——如果你在一个 /* */ 注释里再写一个 /* */,第一个 */ 就会结束整个注释,导致后面的内容被当成代码。
/* 这是单行注释 */
/*
* 这是多行注释
* 可以写很多行内容
*/
int age = 18; /* 行内注释也是允许的 */// 形式(C99 新增):从 // 到行尾都是注释。
// 这是单行注释,C99 标准引入
int age = 18; // 行尾注释写注释是一个程序员的素养体现——不是为了交作业,而是为了三个月后的自己或同事能看懂你当时在想什么。一个没有注释的项目,维护成本会随着时间指数级增长。
注释的四个细节
最后提醒几个注释相关的细节。
第一,注释不能放在双引号里面——printf("// hello /* world */ ") 中的 // 和 /* */ 都是字符串的一部分,会被原样打印出来,而不是当作注释。编译器处理时,字符串优先于注释。
第二,编译时注释会被替换成一个空格,所以 min/*这里是注释*/Value 会变成 min Value 而不是 minValue——不要在标识符中间插注释。
第三,/* */ 注释如果忘记写结束符 */,注释会一直"吞"掉后面的所有内容,直到下一个 */ 出现,把本该执行的代码也一并吞掉,这是 /* */ 注释最常见的翻车方式。后果往往很隐蔽:程序"莫名其妙"少了一段逻辑。排查这种问题,可以先看代码有没有被"灰化"(VS 里注释是绿色,没写结束符时大段代码会变绿)。
第四,注释要写"为什么"而不是"是什么"。i++; // i 自增 这种注释等于没说。真正有价值的注释是:i++; /* 跳过结尾的空格,避免下一次循环读到垃圾字符 */——解释代码的意图、约束和陷阱,而不是复述代码本身。
本篇思考题
学完这一篇,试试回答这几个问题,检验一下理解程度:
- 为什么
printf("%d\n", 'A')输出 65,而printf("%c\n", 65)输出 A?这说明了什么? "abc"在内存中占几个字节?如果char s[] = "abc";然后s[3]是什么?printf("c:\nested\dir\n")会输出什么?(提示:数一数里面有几个转义字符)- 一个项目里为什么不能有两个
main函数?如果出现了,是在编译阶段报错还是链接阶段报错? - 试着写出能打印下面内容的程序:
他说:"你好,世界!"(提示:中文可以直接放进字符串,双引号需要转义)
思考题参考答案与详解
第 1 题:printf("%d\n", 'A') 输出 65,因为 %d 让 printf 把 'A' 当作整数读取,而 'A' 的 ASCII 码值正是 65;printf("%c\n", 65) 输出 A,因为 %c 让 printf 把整数 65 翻译成对应的字符 'A'。这恰好印证了本篇强调的结论:字符在 C 语言底层就是整数(char 是一个 1 字节的小号整数)——同一个值用 %d 读就是码值,用 %c 读就是字符。
第 2 题:"abc" 在内存中占 4 个字节:'a'、'b'、'c' 再加上末尾隐藏的 '\0'。声明 char s[] = "abc"; 后,数组实际大小为 4,所以 s[3] 的值就是 '\0'(ASCII 值为 0 的空字符,字符串结束标志)。
第 3 题:这段字符串里"看起来像转义序列"的地方有三处,需要逐个判断:
\nested开头的\n→ 合法转义,是换行符;\dir开头的\d→\d不是合法的转义序列,属于未定义行为,绝大多数编译器(VS、GCC 等)会给出警告并丢弃反斜杠、直接输出字符d;- 末尾的
\n→ 合法转义,换行符。
所以实际输出大致是:
c:
estedir
即第一行输出 c:,换行后输出 estedir(\d 处的 d 混在其中,末尾的 \n 又换了一次行)。这道题的要点是:数转义字符时要区分合法转义(\n)与非法转义(\d)。
第 4 题:因为程序的入口只能有一个。一个项目可以有几十个 .c 文件,但必须且只能有一个 main。出现两个 main 时,报错发生在链接阶段(如 VS 报 LNK2005: main already defined)——因为编译阶段每个 .c 是独立处理的、各自都能通过,直到链接器把所有文件合并时才发现有"两个 main 入口符号"冲突。
第 5 题:
#include <stdio.h>
int main()
{
/* 中文可以直接放进字符串;字符串内部的双引号要用 \" 转义 */
printf("他说:\"你好,世界!\"\n");
return 0;
}中文不需要任何特殊处理(直接写在字符串里即可),只有字符串内部的双引号需要转义成 \",否则编译器会误把那个 " 当成字符串的结束符。
小结
从 C 语言的历史聊到编译器的选择,从第一个 hello C 程序深入到 main 函数、printf 和库函数的概念,再到 32 个关键字、ASCII 编码、字符串末尾神秘的 \0、花样百出的转义字符,以及五类语句和两种注释方式——这就是你 C 语言学习之旅的起点。现在闭上眼睛回想一下:char、int、printf、%d、%c、%s、\n、\0、ASCII 值、关键字、标识符……这些概念在脑子里应该已经有了初步的印象。
如果有些地方还模糊,没关系。编程学习最忌讳的就是"全部弄懂再往下走"——有些概念要在后面的学习中反复使用,才能逐渐内化。下一篇文章,我们将进入一个更"实在"的话题:C 语言的数据类型和变量——这些"内存中的容器"是怎么工作的。准备好了吗?
还没有评论 — 第一条由你来留。