Lesson 01: 最简单的 C 程序 CNB
练习任务
编写一个最简单的、能通过编译的 C 程序。这个程序不需要输出任何文字,不需要做任何计算,只需能成功编译并运行。
提示:想一想,C 程序必须包含什么?哪个函数是程序的入口?入口函数需要返回什么?
核心知识点
- 程序是什么 — 机器码、源代码与编译的概念
main函数 — 程序的入口约定,Unix/K&R 的历史渊源return 0— 退出状态码的约定,$?在 shell 中的使用int返回类型 — 为什么 C 标准要求int,void main()为什么不合法- 函数定义语法 — 返回类型、函数名、参数列表、复合语句的四段式结构
- 分号
;— C 语言的语句终止符(与 Python 的行式语法的根本区别) - 花括号
{}— 块作用域的定义 - 注释 — 块注释
/* */与行注释//(C99)的语法与陷阱 - 空白符与格式化约定 — 自由格式语言,空格何时必须、何时可选
- 编译管线 — 预处理 → 编译器 → 汇编器 → 链接器,gcc 分步操作
- 空程序的行为 — 一个
return 0的程序到底做了什么 - 程序生命周期 — 启动、执行、返回、终止的全过程
#include指令 — 简介(详细见 Lesson 02)- C 标准时间线 — K&R C → C89/C90 → C99 → C11 → C17 → C23
- 程序的内存驻留 — 代码段与 main 的栈帧
void参数列表 — 显式声明「无参数」的精确含义
代码框架
在开始编写之前,请先理解以下框架结构:
返回类型 函数名(参数列表)
{
函数体
}请尝试填充这个框架,写出你自己的版本。如果你完成了,可以继续阅读下文深入了解每个知识点。
TIP
先不要往下翻看参考解答,尝试自己动手写出完整的程序。即使只是 int main(void) { return 0; } 这样短短一行,背后也蕴含着丰富的知识体系——从操作系统的进程模型到编译器的语法分析,从 C 语言的历史演进到现代 CPU 的指令集。
深度讲解
1. 程序是什么:从机器码到源代码
在写第一行 C 代码之前,我们先回答一个根本问题:什么是一个程序?
1.1 计算机只认识机器码
计算机的中央处理器(CPU)只理解一种语言——机器码(machine code)。机器码就是一系列二进制数字,每一条指令告诉 CPU 做一件极其微小的事情。例如,在 x86 架构上,10111000 00000000 00000000 这条机器指令的意思是「把数字 0 放入 eax 寄存器」。
下面是一个完整的 x86 机器码程序的十六进制表示(Linux ELF 格式),它做的事情就是「返回 0」:
7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00
02 00 03 00 01 00 00 00 54 80 04 08 34 00 00 00
...
B8 00 00 00 00 C3最后两条指令(B8 00 00 00 00 和 C3)就是 mov eax, 0 和 ret——把 0 放入返回值寄存器,然后返回。
NOTE
在计算机的早期(1940-1950 年代),程序员确实是这样编程的——直接输入二进制数字,或使用打孔卡上的孔来表示 0 和 1。这被称为「第一代编程语言」。
1.2 汇编语言:给机器码起个名字
机器码对人类极不友好。于是人们发明了汇编语言(assembly language)——用助记符(mnemonic)来代替二进制指令。上面那个程序用汇编写就是:
main:
movl $0, %eax # 把 0 放入返回值寄存器
ret # 返回调用者汇编器(assembler)负责把这些助记符翻译回机器码。汇编语言比机器码好读了一万倍,但它仍然极度贴近硬件——你必须关心寄存器、栈帧、调用约定等底层细节。
1.3 高级语言与 C 的诞生
到了 1960-1970 年代,人们想要一种「更接近人类思维」的编程方式,同时又不失去对硬件的精细控制。这就是高级语言(high-level language)的使命。
C 语言于 1969-1973 年间在贝尔实验室由 Dennis Ritchie 创造。C 的定位非常特殊——它是一门「高级汇编语言」:
- 比汇编语言抽象得多(有类型系统、控制流、函数抽象)
- 比大多数高级语言更贴近硬件(可以直接操作内存、位运算)
- 被设计用来编写操作系统(Unix 就是用 C 重写的)
同一个「返回 0」的程序,用 C 写就是:
int main(void)
{
return 0;
}这个 C 程序经由编译器(compiler)翻译,最终变成和上面那条汇编一样的机器码。
1.4 源代码、编译器与可执行文件
┌───────────┐ 编译器 ┌──────────────┐
│ 源代码 │ ─────────→ │ 可执行文件 │
│ (.c 文件) │ (翻译过程) │ (机器码) │
└───────────┘ └──────────────┘- 源代码(source code):人类可读的文本文件,用 C 语言编写
- 编译器(compiler):一个翻译程序,把 C 源代码翻译为机器码
- 可执行文件(executable):机器码的最终形式,操作系统可以直接加载运行
IMPORTANT
编译器不是「解释器」——C 是编译型语言。源代码在运行之前必须被完整翻译成机器码。这和 Python、JavaScript 等解释型语言(逐行执行)有着根本区别。
2. main 函数:程序的入口点
2.1 为什么是 main?
操作系统在加载一个 C 程序后,需要知道「从哪里开始执行第一行代码」。C 语言的约定是:从 main 函数开始。
这个约定可以追溯到 C 语言的祖先——B 语言和更早的 BCPL 语言。在 Unix 操作系统的发展过程中,main 逐渐固化为程序入口的标准名称。
NOTE
main 不是 C 语言的关键字(keyword)。它只是一个约定俗成的函数名。C 语言的关键字包括 int、return、if、while、for 等——但 main 不在其中。你可以定义一个名为 main 的变量(虽然不推荐):
int main = 42; // 合法!但程序无法正常运行(没有真正的 main 函数)链接器(linker)默认将 main 作为程序的入口符号。通过编译器选项可以修改入口点,例如 GCC 的 -e 选项:
gcc -e my_start program.c -o program # 将 my_start 函数作为入口但在正常的 C 编程中,永远使用 main。
2.2 main 的两种标准签名
ISO C 标准规定了 main 的两种合法签名:
// 无参数版本 — 程序不关心命令行参数
int main(void)
// 有参数版本 — 程序接收命令行参数
int main(int argc, char *argv[])在 Unit 0 中,我们使用 int main(void)。argc 和 argv 的详细用法将在后续课程(Lesson 14 命令行参数)中讲解。
2.3 main 的调用者:crt0
main 函数并不是程序的第一行被执行代码。在 main 被调用之前,有一段称为 C 运行时启动代码(C Runtime Startup,通常命名为 crt0.o)的代码会先执行。它负责:
- 设置栈指针和栈帧
- 初始化全局变量(从数据段加载已初始化变量的值,清零 BSS 段)
- 将命令行参数
argc和argv传递给main - 调用
main() - 当
main返回后,调用exit()将退出码传递给操作系统
操作系统 (OS)
↓ 加载并执行
crt0 (C 运行时启动)
↓ 初始化环境后调用
main() ← 你的代码从这里开始
↓ return 0
crt0
↓ exit(0)
操作系统 (进程结束)这意味着 main 的 return 并不是直接返回给操作系统,而是返回给 crt0,再由 crt0 通过 exit() 系统调用将退出码交给操作系统。
3. return 0:退出状态码
3.1 为什么 main 必须返回 int?
在 C 语言中,main 是程序与操作系统之间的契约入口。操作系统启动程序后,调用 main 函数,并期待它返回一个整数作为「退出状态码」(exit status)。这个状态码告诉操作系统——以及调用这个程序的任何脚本或工具——程序是成功还是失败结束。
根据 ISO C 标准(C89/C99/C11/C17):
如果
main函数的返回类型兼容int,那么从main的初始调用中返回,等价于以该返回值作为参数调用exit()函数。
换言之,return 0; 在 main 中等价于 exit(0);。
3.2 退出状态码的约定
| 返回值 | 含义 |
|---|---|
0 或 EXIT_SUCCESS | 程序成功执行 |
EXIT_FAILURE(通常为 1) | 程序执行失败 |
| 1 ~ 125 | 可由程序自定义的错误码 |
| 126 | POSIX 保留:命令不可执行 |
| 127 | POSIX 保留:命令未找到 |
| 128+ | 表示程序因收到信号而终止 |
IMPORTANT
退出码在大多数操作系统(Unix/Linux/Windows)中被截断为 8 位(0~255)。这意味着 return 256; 的实际退出码是 256 % 256 = 0,return -1; 的实际退出码是 255(-1 的 8 位补码表示)。
3.3 在 shell 中查看退出码:$?
你可以在 shell 中用 $? 查看上一个程序的退出码:
$ ./my_program
$ echo $?
0 # 程序返回 0,表示成功退出码在 shell 脚本中极其有用——它们让脚本可以根据前一个程序是否成功来决定下一步操作:
if gcc program.c -o program; then
echo "编译成功!"
./program
else
echo "编译失败,请检查错误信息。"
exit 1
fiif 语句检查的就是 gcc 的退出码:0 表示编译成功,非 0 表示编译失败。
3.4 void main() 为什么不符合标准?
某些编译器(如老版本的 Turbo C、一些嵌入式环境的编译器)允许 void main(),但这不符合 ISO C 标准:
void main()声明返回类型为void,意味着程序结束时不会向操作系统传递退出状态码- 操作系统无法得知程序的执行状态,这在脚本和自动化工具中会造成严重问题
- 某些编译器可能对
void main()发出警告甚至报错 - C99 及以后的标准明确要求
main的返回类型为int
CAUTION
始终使用 int main(void) 或 int main(int argc, char *argv[])。void main() 是历史遗留问题,现代 C 编程中应完全避免。
3.5 C99/C11 的隐式 return 0
从 C99 标准开始,main 函数有一个特殊规则:如果执行到达 main 的结尾 } 而没有遇到 return 语句,编译器会自动插入 return 0;。
int main(void)
{
// 没有 return 语句,但 C99+ 会自动 return 0
} // 等价于 return 0;WARNING
虽然标准允许省略 return 0;,但强烈建议始终显式写出。原因:
- 显式
return让代码意图更清晰 - C89 标准不支持此行为——在旧编译器上会产生未定义行为
- 养成「函数必须有 return」的习惯,避免在其他函数中遗漏
4. 函数定义语法
4.1 函数定义的四段式结构
一个函数定义由四个关键部分组成:
返回类型 函数名 参数列表 函数体
int main (void) { return 0; }- 返回类型(return type):指定函数返回值的类型。
int表示返回一个整数。 - 函数名(function name):标识符,遵循 C 的命名规则(字母、数字、下划线,不能以数字开头)。对于程序的入口函数,约定为
main。 - 参数列表(parameter list):用括号包裹,列出函数接受的参数及其类型。
(void)表示「没有参数」。 - 函数体(compound statement):用花括号
{}包裹的语句序列。函数体定义了函数实际执行的逻辑。
4.2 BNF 范式分析
BNF(Backus-Naur Form,巴科斯-瑙尔范式)是描述编程语言语法的形式化方法。通过 BNF 范式,我们可以精确理解一段 C 代码的语法结构。
核心推导过程:
- 翻译单元(translation_unit) → 外部声明(external_decl)
- 外部声明 → 函数定义(function_definition)
- 函数定义 → 声明说明符(
int)+ 声明符(main(void))+ 复合语句({ return 0; }) - 声明说明符 → 类型说明符(
int) - 声明符 → 直接声明符 → 标识符 + 参数类型列表(
main+(void)) - 复合语句 →
{声明列表 语句列表}→ 跳转语句(return 0;)
用图解表示:
translation_unit
└── function_definition
├── decl_specs: int
├── declarator: main(void)
│ ├── id: main
│ └── param_type_list: void
└── compound_stat: { return 0; }
└── jump_stat: return 0;理解 BNF 范式有助于读懂编译器的错误信息,也为后续学习更复杂的语法(如函数指针声明)打下基础。
5. 基本语法元素
5.1 分号:语句终止符
在 C 语言中,分号 ; 是语句的终止符(statement terminator),不是分隔符。每条语句必须以分号结尾:
int a = 1; // 赋值语句,必须以 ; 结尾
int b = 2; // 赋值语句
return a + b; // return 语句这一点与 Python 等语言有根本区别。Python 使用换行来分隔语句:
# Python: 换行分隔语句
a = 1
b = 2而在 C 中,换行只是空白符,不影响语法。你可以把所有代码写在一行:
int main(void){return 0;}NOTE
C 是自由格式语言(free-form language)。换行、缩进、空格对编译器而言仅仅是 token 分隔符。代码排版是为了人类的可读性,不是编译器的要求。
5.2 花括号:块作用域
花括号 { 和 } 定义了一个代码块(block),也称为复合语句(compound statement)。代码块有两个作用:
- 语法分组:将多条语句组织为一个整体(例如函数体、if 语句体、循环体)
- 作用域界定:在块内定义的变量,其作用域仅限于该块
int main(void)
{ // ← 函数体开始
int a = 1; // a 的作用域是整个 main 函数体
{ // ← 嵌套块开始
int b = 2; // b 的作用域仅限于这个嵌套块
a = a + b; // 可以访问外层的 a
} // ← b 的生命周期结束
// b 在这里不可见
return a;
} // ← a 的生命周期结束TIP
花括号的对齐风格(K&R 风格、Allman 风格、GNU 风格等)是程序员之间永不过时的话题。在本教程中,我们使用 K&R 风格:左花括号与函数头同行,右花括号独占一行。这是 C 语言最传统、使用最广泛的风格。
5.3 空白符:何时必须、何时可选
C 语言中大部分空格和换行可以省略,但有些空格是必须的——当两个相邻的 token 都是字母/数字组成的标识符或关键字时,它们之间必须有分隔符:
int main(void){return 0;} // 正确
intmain(void){return 0;} // 错误!intmain 被当作一个标识符
int a = 1, b = 2; // 正确
inta = 1, b = 2; // 错误!inta 被当作一个标识符规则:当两个 token 都是字母数字序列时,它们之间必须有至少一个空白符(空格、换行、制表符)或标点符号(括号、运算符等)来分隔。
可以省略的空格:
int a=1+2; // 运算符两侧的空格可以省略,功能完全相同
int a = 1 + 2; // 但加上空格更易读字符串内的空格不可省略:
printf("hello world"); // 输出 hello world
printf("helloworld"); // 输出 helloworld(不同的字符串!)5.4 注释:给人类看的文字
C 语言支持两种注释形式:
| 类型 | 语法 | 特点 |
|---|---|---|
| 块注释 | /* ... */ | 不能嵌套,可跨越多行 |
| 行注释 | // ... | C99 引入,到行尾结束,可以嵌套 |
/* 这是块注释 */
// 这是行注释
/* 块注释 /* 不能嵌套 */ 这会导致编译错误 */
// 行注释可以包含 // 另一个行注释,没问题注释可以出现在代码行的中间:
int x = 10; // 行内注释:x 赋值为 10
int y = /* 块注释也可以 */ 20; // y 赋值为 20WARNING
块注释中不小心写入了 */ 会提前结束注释。在调试时如果需要临时「注释掉」一大段包含块注释的代码,使用 #if 0 ... #endif 是更安全的选择:
#if 0
/* 这段代码暂时不需要 */
int old_code = 42;
/* 内部的块注释也不会出问题 */
#endif注释最佳实践:
- 注释应该解释为什么(why),而非是什么(what)——代码本身应该说明「是什么」
- 保持注释简洁、准确、与代码同步
- 避免无意义的注释:
int x = 10; // 给 x 赋值为 10(代码已经说明了)
6. 编译管线:从 .c 到可执行文件
一个 C 程序从源码到可执行文件,经历四个阶段:
源码 (.c) → [预处理] → 预处理输出 (.i) → [编译] → 汇编 (.s) → [汇编] → 目标文件 (.o) → [链接] → 可执行文件| 阶段 | 工具 | 作用 |
|---|---|---|
| 预处理(Preprocessing) | cpp | 处理 #include、#define、#if 等预处理指令 |
| 编译(Compilation) | cc1 | 将预处理后的代码转换为汇编语言 |
| 汇编(Assembly) | as | 将汇编代码转换为目标文件(机器码) |
| 链接(Linking) | ld | 将目标文件与库文件合并为可执行文件 |
6.1 GCC 分步编译
使用 GCC 可以一步步查看每个阶段的输出:
# 一步到位(最常用的方式)
gcc simple.c -o simple
# 分步查看编译过程
gcc -E simple.c -o simple.i # 1. 预处理:展开所有 #include、#define
gcc -S simple.i -o simple.s # 2. 编译:生成汇编代码
gcc -c simple.s -o simple.o # 3. 汇编:生成目标文件(机器码)
gcc simple.o -o simple # 4. 链接:生成可执行文件NOTE
-o 标志指定输出文件名(output)。如果不使用 -o,GCC 默认输出 a.out——这是 Unix 历史遗留的命名(a.out = assembler output)。
6.2 最简程序的编译过程
对于最简程序 int main(void) { return 0; }(没有 #include),预处理阶段几乎不做任何处理——因为没有预处理指令需要展开。但编译、汇编、链接仍然完整执行。
我们来实际看看编译过程:
# 创建最简程序
$ cat > simple.c << 'EOF'
int main(void)
{
return 0;
}
EOF
# 预处理(几乎无变化)
$ gcc -E simple.c -o simple.i
# simple.i 内容与 simple.c 几乎相同
# 编译为汇编
$ gcc -S simple.i -o simple.s生成的汇编代码(x86 AT&T 语法):
main:
pushl %ebp # 保存旧的基址指针
movl %esp, %ebp # 建立新的栈帧
movl $0, %eax # 将返回值 0 放入 eax 寄存器
popl %ebp # 恢复旧的基址指针
ret # 返回调用者关键观察:
- 返回值通过
%eax寄存器传递(x86 调用约定)。这就是为什么main返回int:操作系统从%eax中读取退出状态码 pushl/movl/popl是栈帧管理的标准模式,每个函数调用都会执行- 汇编层面没有「函数签名」的概念,只有寄存器和内存
6.3 为什么需要链接?
你可能会问:我们的程序只有一个文件,为什么还需要链接?因为即使是最简程序,也需要链接到 C 运行时库(crt0.o),它包含了程序的真正入口点 _start,以及程序退出时需要调用的系统函数。
用 ldd 可以查看可执行文件依赖的动态库:
$ ldd simple
linux-vdso.so.1 (0x00007ffd...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)即使是 return 0 这样简单的程序,也链接了 C 标准库 libc。
7. 空程序的行为:到底发生了什么?
7.1 程序的生命周期
当一个 C 程序运行时,它经历了以下完整生命周期:
1. 加载
操作系统读取可执行文件头(ELF 格式),
将代码段、数据段映射到内存,
分配栈空间,创建进程
2. 初始化 (crt0)
设置栈指针、清零 BSS 段、
初始化全局变量、准备 argc/argv
3. 调用 main()
你的代码开始执行
4. 执行函数体
在 Unit 0 的最简程序中,这里只有一个 return 0;
5. 返回
main 返回 0 → crt0 收到 0 → exit(0)
→ 操作系统回收所有资源(内存、文件描述符等)
6. 终止
进程结束,退出码 0 可供父进程或 shell 查询7.2 一个 return 0 到底做了什么?
即使是最简程序 int main(void) { return 0; },在运行时也执行了大量的底层操作:
- 栈帧的建立和销毁:
push ebp; mov ebp, esp→pop ebp - 寄存器的保存和恢复:调用约定要求被调用者保存某些寄存器
- 系统调用:
exit(0)最终触发exit_group或exit系统调用 - 内核操作:操作系统内核释放进程的虚拟内存映射、关闭所有打开的文件描述符、更新进程表、向父进程发送 SIGCHLD 信号
NOTE
你可以用 strace 追踪一个程序执行的所有系统调用:
$ strace ./simple
execve("./simple", ["./simple"], ...) = 0
...
exit_group(0) = ?
+++ exited with 0 +++输出可能包含数十行——即使是「空」程序,操作系统也为它做了大量工作。
8. C 语言的历史与标准
8.1 C 语言的起源
C 语言的诞生与 Unix 操作系统密不可分:
- 1969 年:Ken Thompson 在 PDP-7 上用汇编语言编写了第一版 Unix
- 1970 年:Thompson 创造了 B 语言(BCPL 的简化版),用来重写 Unix
- 1972 年:Dennis Ritchie 在 B 语言的基础上加入类型系统,创造了 C 语言
- 1973 年:Unix 内核的大部分被用 C 重写——这是操作系统历史上第一次用高级语言编写内核
C 的设计理念深受 Unix 哲学的影响:简单、模块化、做一件事并做好。
8.2 C 标准时间线
| 标准 | 年份 | 主要变化 |
|---|---|---|
| K&R C | 1978 | 《The C Programming Language》出版,成为事实标准 |
| C89 / C90 | 1989 | 第一个 ISO 标准(ISO/IEC 9899:1990),32 个关键字 |
| C95 | 1995 | 小幅修订,增加宽字符支持 |
| C99 | 1999 | 增加 // 行注释、long long、inline、变长数组、restrict 等 |
| C11 | 2011 | 增加 _Generic、_Alignas、_Atomic、多线程支持 |
| C17 | 2017 | 主要是缺陷修复,没有新增特性 |
| C23 | 2023 | 增加 bool/true/false 关键字、nullptr、#embed 等 |
IMPORTANT
本教程基于 C11/C17 标准,这是目前最广泛支持的现代 C 标准。C23 是最新的标准,但编译器和工具链的支持仍在逐步完善中。
8.3 C 与 C++ 的关系
C 和 C++ 是两门不同的语言,但它们共享大量语法:
- C++ 最初是「C with Classes」,由 Bjarne Stroustrup 在 1979 年创建
- C++ 几乎完全兼容 C89(有少量不兼容的细节)
- C 强调简洁和透明——「你所写即所得」
- C++ 增加了面向对象、模板、异常处理等特性
NOTE
许多初学者容易混淆 C 和 C++。一个快速辨别的方法:如果代码中有 class、cout、namespace、new/delete,那就是 C++。C 使用 printf、malloc/free,没有类和命名空间。
9. #include 指令简介
到目前为止,我们的最简程序不需要任何外部函数——main 和 return 都是 C 语言内置的,不需要额外的声明。但从下一课(Lesson 02:Hello World 与 printf)开始,我们将频繁使用标准库函数,这时就需要 #include 指令。
#include 是一个预处理指令——它在编译之前由预处理器处理:
#include <stdio.h> // 将 stdio.h 文件的内容复制到此处
int main(void)
{
printf("hello, world.\n"); // printf 声明在 stdio.h 中
return 0;
}#include <stdio.h>告诉预处理器:「把系统头文件stdio.h的全部内容复制到这一行」stdio.h中包含了printf等函数的声明——编译器需要知道这些函数的返回类型和参数类型才能正确编译- 尖括号
<...>表示在系统头文件目录中搜索(如/usr/include/) - 双引号
"..."表示先在当前目录搜索,再到系统目录搜索(用于包含自己写的头文件)
#include 的详细机制将在 Lesson 02 中深入讲解——这里只需要知道它的基本作用即可。
参考解答
最简 C 程序
int main(void)
{
return 0;
}这是一个完整的、符合 ISO C 标准的 C 程序。它没有输出任何文字,没有做任何计算——但它确实是一个合法的程序,编译运行后会以退出码 0 告诉操作系统:「一切顺利」。
这是你能写出的最短、最简的合法 C 程序。不要小看这短短三行代码——它包含了 C 语言的核心骨架:返回类型(int)、函数名(main)、参数列表(void)、函数体({ return 0; })、以及语句终止符(;)。每一个 C 程序,无论多么复杂,都是在这个骨架上生长出来的。
扩展:带变量与表达式的程序
/* this is the simplest c program */
int global = 1;
int main(void)
{
int local = 2;
// we return these two variable's summary
return local + global;
}这个程序引入了全局变量、局部变量和算术表达式。return local + global; 的返回值为 3,你可以通过 echo $? 在 shell 中验证。
相比最简版本,这个程序展示了:
- 全局变量(
global)定义在函数外部,存储在数据段 - 局部变量(
local)定义在函数内部,存储在栈上 - 算术表达式
local + global在return语句中被求值 - 注释的使用:块注释
/* */和行注释//
课堂讨论
main函数名是 C 语言的关键字吗?- 注释可以写在某一行代码的中间吗?有哪些陷阱?
- 全局变量和局部变量可以重名吗?重名后会发生什么?
- 所有代码可以写在一行里面吗?C 语言对格式有什么要求?
- 把源程序中的空格去掉可以吗?哪些空格是必须的?
- 为什么全局变量会自动初始化为 0,而局部变量不会?
讨论答案
Q1: main 函数名是 C 语言的关键字吗?
不是。 main 只是一个约定俗成的函数名,不是 C 语言的关键字(keyword/reserved word)。
C 语言的关键字包括 int、return、if、while、for 等——C89 共 32 个,C99 增加 5 个(inline、restrict、_Bool、_Complex、_Imaginary),C11 增加 _Alignas、_Alignof、_Atomic、_Generic、_Noreturn、_Static_assert、_Thread_local——但 main 不在其中。
为什么 main 如此特殊?
- 链接器(linker)默认将
main作为程序的入口点 - 操作系统加载程序后,C 运行时(crt0)会调用
main函数 - 这个约定源自 Unix 和 C 语言的早期发展,是一种设计上的共识,而非语法的强制
验证方式:你可以定义一个名为 main 的变量(虽然不推荐):
int main = 42; // 合法!但程序无法正常运行(没有真正的 main 函数)编译器不会报错,但链接器会找不到入口点 main(因为现在 main 是一个变量,不是函数),导致链接失败。
Q2: 注释可以写在某一行代码的中间吗?有哪些陷阱?
可以。 行注释 // 和块注释 /* */ 都可以出现在代码行的中间。
行注释(//):从 // 开始到行尾,之后的内容被注释掉,之前的内容正常执行。
int x = 10; // 这是行内注释,x 赋值为 10
int y = /* 块注释也可以 */ 20; // y 赋值为 20陷阱:
- 块注释不能嵌套:
/* 外层 /* 内层 */ 外层继续 */会因为第一个*/就结束注释,导致「外层继续 */」被当作代码,产生编译错误 - 字符串中的注释符号是普通字符:
printf("// 这不是注释\n");会正常输出// 这不是注释 - 块注释不能跨越预处理指令:
#include等预处理指令必须在注释之外
最佳实践:行内注释应简洁,解释「为什么」而非「是什么」。代码本身应该说明「是什么」。
Q3: 全局变量和局部变量可以重名吗?重名后会发生什么?
可以重名。 当全局变量和局部变量同名时,在局部变量的作用域内,局部变量会遮蔽(shadow)全局变量。
int value = 100; // 全局变量
int main(void)
{
int value = 200; // 局部变量,遮蔽了全局 value
printf("%d\n", value); // 输出 200,访问的是局部变量
return 0;
}遮蔽规则:
- 编译器查找变量时,从最内层作用域向外查找,找到第一个匹配的就停止
- 被遮蔽的全局变量在当前作用域内无法直接访问
- 这是合法的 C 代码,但属于不良编程实践,容易导致混淆和 bug
建议:避免使用相同名称,或使用命名约定区分(如全局变量加 g_ 前缀:g_value)。
Q4: 所有代码可以写在一行里面吗?C 语言对格式有什么要求?
可以。 C 语言是自由格式语言(free-form language),空格、换行、缩进对编译器来说几乎不重要(在词法层面,它们只是 token 分隔符)。
int main(void){return 0;}上面这行代码完全合法,与多行版本等价。
但强烈不推荐! 原因:
- 可读性极差:人类阅读代码需要视觉结构。多行缩进、空行分隔逻辑块,这些对理解代码至关重要
- 难以调试:编译器报错时给出的行号将失去意义——所有错误都在「第 1 行」
- 协作困难:其他人无法维护这样的代码
例外:某些场合刻意压缩代码(如代码混淆竞赛 IOCCC——International Obfuscated C Code Contest),但这是娱乐性质,不是工程实践。
Q5: 把源程序中的空格去掉可以吗?哪些空格是必须的?
大部分空格可以去掉,但有些空格是必须的。
必须保留的空格(分隔 token):
int main(void){return 0;} // 正确
intmain(void){return 0;} // 错误!intmain 被当作一个标识符
int a = 1, b = 2; // 正确
inta = 1, b = 2; // 错误!inta 被当作一个标识符规则:当两个相邻的 token 都是字母/数字组成的标识符或关键字时,它们之间必须有空格(或其他分隔符,如括号、运算符)来区分。这是因为 C 的词法分析器采用「最长匹配」原则——它会尽可能多地读取字符来构成一个 token。
可以省略的空格:
int a = 1 + 2; // 有空格,可读
int a=1+2; // 无空格,同样合法字符串内的空格不可省略:
printf("hello world"); // 输出 hello world
printf("helloworld"); // 输出 helloworld(不同的字符串!)Q6: 为什么全局变量会自动初始化为 0,而局部变量不会?
根本原因:内存存储位置和初始化机制的差异。
全局变量(静态存储期):
- 存储在数据段(.data,已初始化)或 BSS 段(.bss,未初始化)
- 操作系统在加载程序时,会将 BSS 段整块清零
- 这是 ELF 可执行文件格式的要求——BSS 段只记录大小,不占用文件空间,加载时由操作系统分配并清零
- 因此,未显式初始化的全局变量保证为 0
局部变量(自动存储期):
- 存储在栈上
- 栈空间被反复使用——函数调用时分配栈帧,返回时释放
- 新分配的栈空间包含的是上一次使用后残留的值(垃圾值)
- 自动初始化为 0 需要编译器插入额外的清零指令(
memset),这会带来运行时开销
C 语言的设计哲学:「不为你用不到的东西付费」(zero-overhead principle)。如果程序员需要初始值,可以显式初始化;如果不需要,不应强制付出性能代价。
// 全局变量:自动清零的代价在程序启动时一次性支付
int global_array[1000000]; // 加载时由 OS 清零,几乎零开销
// 局部变量:每次函数调用都清零将严重影响性能
void func(void) {
int local_array[1000000]; // 编译器不自动清零
}WARNING
使用未初始化的局部变量是典型的「未定义行为」(Undefined Behavior, UB)。编译器不会报错,但程序行为不可预测——每次运行可能得到不同的结果。始终在定义变量时赋予初始值。
课后练习
练习 1:不同退出码的程序
编写一个 C 程序,return 不同的退出码(如 return 42;),编译运行后用 echo $? 查看退出码。尝试 return 256; 和 return -1;,观察实际退出码与预期是否一致。
知识点提示:退出码被截断为 8 位(0~255),
256 % 256 = 0,-1的 8 位表示为255。
参考解答
int main(void)
{
return 42;
}$ gcc exit_code_42.c -o exit_code_42
$ ./exit_code_42
$ echo $? # 输出 42
# 测试 256
$ cat > exit_256.c << 'EOF'
int main(void) { return 256; }
EOF
$ gcc exit_256.c -o exit_256 && ./exit_256 && echo $? # 输出 0(256 被截断)
# 测试 -1
$ cat > exit_neg1.c << 'EOF'
int main(void) { return -1; }
EOF
$ gcc exit_neg1.c -o exit_neg1 && ./exit_neg1 && echo $? # 输出 255(-1 的 8 位补码)练习 2:全局变量与局部变量的组合
编写一个 C 程序,定义一个全局变量 int g_x = 10;,在 main 中定义局部变量 int l_y = 20;,计算 g_x + l_y 并作为返回值。编译运行后用 echo $? 验证。
知识点提示:全局变量存储在数据段,局部变量存储在栈上。返回值通过
%eax寄存器传递。
参考解答
int g_x = 10;
int main(void)
{
int l_y = 20;
return g_x + l_y; // 返回 30
}$ gcc sum_global_local.c -o sum_global_local
$ ./sum_global_local
$ echo $? # 输出 30练习 3:运算符优先级实验
编写一个 C 程序,计算以下表达式的值并作为返回值(每个表达式单独写一个程序),用 echo $? 验证你的预期:
1 + 2 * 3→ 预期值是多少?(1 + 2) * 3→ 预期值是多少?10 % 3 + 5→ 预期值是多少?
知识点提示:
*优先级高于+;%优先级与*、/相同。
参考解答
// 表达式 1:1 + 2 * 3 = 1 + 6 = 7
int main(void) { return 1 + 2 * 3; }// 表达式 2:(1 + 2) * 3 = 3 * 3 = 9
int main(void) { return (1 + 2) * 3; }// 表达式 3:10 % 3 + 5 = 1 + 5 = 6
int main(void) { return 10 % 3 + 5; }$ gcc expr1.c -o expr1 && ./expr1 && echo $? # 7
$ gcc expr2.c -o expr2 && ./expr2 && echo $? # 9
$ gcc expr3.c -o expr3 && ./expr3 && echo $? # 6练习 4:变量遮蔽实验
编写一个 C 程序,定义全局变量 int x = 100;,在 main 中定义同名局部变量 int x = 200;,返回 x。结果是 100 还是 200?为什么?
知识点提示:局部变量遮蔽全局变量。编译器从最内层作用域向外查找。
参考解答
int x = 100;
int main(void)
{
int x = 200; // 遮蔽全局变量 x
return x; // 返回局部变量 x,值为 200
}$ gcc shadow_test.c -o shadow_test && ./shadow_test && echo $? # 200结果是 200,因为局部变量遮蔽了全局变量。在 main 函数内部,编译器找到局部变量 x 就停止查找,不会继续向外找全局变量 x。
练习 5(挑战):用 size 命令探查内存布局
编写两个程序:
prog_data.c:包含int global = 42;(已初始化,存数据段)prog_bss.c:包含int global;(未初始化,存 BSS 段)
分别编译,用 size 命令对比两者的 text/data/bss 各段大小。解释为什么 prog_bss.c 的 data 段更小,而 bss 段更大。
知识点提示:已初始化的全局变量占用可执行文件的 data 段空间;未初始化的全局变量在 BSS 段只记录大小,不占用文件空间。
参考解答
int global = 42;
int main(void) { return global; }int global;
int main(void) { return global; } // global 自动初始化为 0,返回 0$ gcc prog_data.c -o prog_data
$ gcc prog_bss.c -o prog_bss
$ size prog_data prog_bss
text data bss dec hex filename
1418 604 8 2030 7ee prog_data
1418 600 12 2030 7ee prog_bss
# prog_data 的 data 段多了 4 字节(存储 global = 42 的值)
# prog_bss 的 bss 段多了 4 字节(记录 global 的大小,但不存储值)解释:prog_data.c 中 global = 42 的值需要存储在可执行文件的 data 段中,因此 data 段多了 4 字节。prog_bss.c 中 global 未初始化,只需要在 BSS 段记录「需要 4 字节空间」,加载时由操作系统分配并清零,不占用可执行文件空间。
练习 6(挑战):常见编译错误收集
故意在程序中引入以下错误,记录 GCC 的报错信息,理解每条错误信息的含义:
- 缺少分号:
int main(void) { return 0 }(缺少;) - 花括号不匹配:
int main(void) { return 0;(缺少}) - 拼写错误:
int mian(void) { return 0; }(main拼成mian) - 错误的返回类型:
void main(void) { return 0; } - 缺少返回语句:
int main(void) { }(用-Wall -std=c89编译)
知识点提示:编译器错误信息是程序员最重要的调试工具之一。学会阅读和理解错误信息,是编程的基本功。
参考解答
int main(void) { return 0 } // 缺少分号GCC 报错示例:error: expected ';' before '}' token
int main(void) { return 0; // 缺少 }GCC 报错示例:error: expected declaration or statement at end of input
int mian(void) { return 0; } // main 拼错GCC 报错示例:undefined reference to 'main'(链接阶段报错,因为找不到入口点 main)
void main(void) { return 0; } // 不符合标准GCC 报错示例(使用 -Wall -pedantic):warning: return type of 'main' is not 'int'
int main(void) { } // 没有 return(C89 模式)GCC 报错示例(使用 -Wall -std=c89):warning: control reaches end of non-void function
参考资料
- B 语言 — C 语言的前身
- The Development of the C Language — Dennis Ritchie 的自述,详述了 C 语言的诞生过程
- C 语言 BNF 范式 — C 语言语法的形式化定义
- C 程序内存布局详解 — 深入理解代码段、数据段、BSS、堆、栈
- Exit Status — Wikipedia — 退出状态码的标准约定
- ISO C 标准 — C17 标准文档(ISO/IEC 9899:2018)
"Many of the improvements I introduced when developing C simply looked like a good thing to do." — Dennis Ritchie