跳转到内容

Lesson 01: 最简单的 C 程序 CNB

练习任务

编写一个最简单的、能通过编译的 C 程序。这个程序不需要输出任何文字,不需要做任何计算,只需能成功编译并运行。

提示:想一想,C 程序必须包含什么?哪个函数是程序的入口?入口函数需要返回什么?

核心知识点

  • 程序是什么 — 机器码、源代码与编译的概念
  • main 函数 — 程序的入口约定,Unix/K&R 的历史渊源
  • return 0 — 退出状态码的约定,$? 在 shell 中的使用
  • int 返回类型 — 为什么 C 标准要求 intvoid main() 为什么不合法
  • 函数定义语法 — 返回类型、函数名、参数列表、复合语句的四段式结构
  • 分号 ; — C 语言的语句终止符(与 Python 的行式语法的根本区别)
  • 花括号 {} — 块作用域的定义
  • 注释 — 块注释 /* */ 与行注释 //(C99)的语法与陷阱
  • 空白符与格式化约定 — 自由格式语言,空格何时必须、何时可选
  • 编译管线 — 预处理 → 编译器 → 汇编器 → 链接器,gcc 分步操作
  • 空程序的行为 — 一个 return 0 的程序到底做了什么
  • 程序生命周期 — 启动、执行、返回、终止的全过程
  • #include 指令 — 简介(详细见 Lesson 02)
  • C 标准时间线 — K&R C → C89/C90 → C99 → C11 → C17 → C23
  • 程序的内存驻留 — 代码段与 main 的栈帧
  • void 参数列表 — 显式声明「无参数」的精确含义

代码框架

在开始编写之前,请先理解以下框架结构:

skeleton.c
c
返回类型 函数名(参数列表)
{
    函数体
}

请尝试填充这个框架,写出你自己的版本。如果你完成了,可以继续阅读下文深入了解每个知识点。

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 00C3)就是 mov eax, 0ret——把 0 放入返回值寄存器,然后返回。

NOTE

在计算机的早期(1940-1950 年代),程序员确实是这样编程的——直接输入二进制数字,或使用打孔卡上的孔来表示 0 和 1。这被称为「第一代编程语言」。

1.2 汇编语言:给机器码起个名字

机器码对人类极不友好。于是人们发明了汇编语言(assembly language)——用助记符(mnemonic)来代替二进制指令。上面那个程序用汇编写就是:

simple.s
asm
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 写就是:

simplest.c
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 语言的关键字包括 intreturnifwhilefor 等——但 main 不在其中。你可以定义一个名为 main 的变量(虽然不推荐):

main_variable.c
c
int main = 42;  // 合法!但程序无法正常运行(没有真正的 main 函数)

链接器(linker)默认将 main 作为程序的入口符号。通过编译器选项可以修改入口点,例如 GCC 的 -e 选项:

bash
gcc -e my_start program.c -o program   # 将 my_start 函数作为入口

但在正常的 C 编程中,永远使用 main

2.2 main 的两种标准签名

ISO C 标准规定了 main 的两种合法签名:

main_signatures.c
c
// 无参数版本 — 程序不关心命令行参数
int main(void)

// 有参数版本 — 程序接收命令行参数
int main(int argc, char *argv[])

在 Unit 0 中,我们使用 int main(void)argcargv 的详细用法将在后续课程(Lesson 14 命令行参数)中讲解。

2.3 main 的调用者:crt0

main 函数并不是程序的第一行被执行代码。在 main 被调用之前,有一段称为 C 运行时启动代码(C Runtime Startup,通常命名为 crt0.o)的代码会先执行。它负责:

  1. 设置栈指针和栈帧
  2. 初始化全局变量(从数据段加载已初始化变量的值,清零 BSS 段)
  3. 将命令行参数 argcargv 传递给 main
  4. 调用 main()
  5. main 返回后,调用 exit() 将退出码传递给操作系统
操作系统 (OS)
 加载并执行
crt0 (C 运行时启动)
 初始化环境后调用
main()    ← 你的代码从这里开始
 return 0
crt0
 exit(0)
操作系统 (进程结束)

这意味着 mainreturn 并不是直接返回给操作系统,而是返回给 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 退出状态码的约定

返回值含义
0EXIT_SUCCESS程序成功执行
EXIT_FAILURE(通常为 1)程序执行失败
1 ~ 125可由程序自定义的错误码
126POSIX 保留:命令不可执行
127POSIX 保留:命令未找到
128+表示程序因收到信号而终止

IMPORTANT

退出码在大多数操作系统(Unix/Linux/Windows)中被截断为 8 位(0~255)。这意味着 return 256; 的实际退出码是 256 % 256 = 0return -1; 的实际退出码是 255(-1 的 8 位补码表示)。

3.3 在 shell 中查看退出码:$?

你可以在 shell 中用 $? 查看上一个程序的退出码:

bash
$ ./my_program
$ echo $?
0    # 程序返回 0,表示成功

退出码在 shell 脚本中极其有用——它们让脚本可以根据前一个程序是否成功来决定下一步操作:

bash
if gcc program.c -o program; then
    echo "编译成功!"
    ./program
else
    echo "编译失败,请检查错误信息。"
    exit 1
fi

if 语句检查的就是 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;

implicit_return.c
c
int main(void)
{
    // 没有 return 语句,但 C99+ 会自动 return 0
}  // 等价于 return 0;

WARNING

虽然标准允许省略 return 0;,但强烈建议始终显式写出。原因:

  • 显式 return 让代码意图更清晰
  • C89 标准不支持此行为——在旧编译器上会产生未定义行为
  • 养成「函数必须有 return」的习惯,避免在其他函数中遗漏

4. 函数定义语法

4.1 函数定义的四段式结构

一个函数定义由四个关键部分组成:

返回类型    函数名    参数列表      函数体
  int       main     (void)     { return 0; }
  1. 返回类型(return type):指定函数返回值的类型。int 表示返回一个整数。
  2. 函数名(function name):标识符,遵循 C 的命名规则(字母、数字、下划线,不能以数字开头)。对于程序的入口函数,约定为 main
  3. 参数列表(parameter list):用括号包裹,列出函数接受的参数及其类型。(void) 表示「没有参数」。
  4. 函数体(compound statement):用花括号 {} 包裹的语句序列。函数体定义了函数实际执行的逻辑。

4.2 BNF 范式分析

BNF(Backus-Naur Form,巴科斯-瑙尔范式)是描述编程语言语法的形式化方法。通过 BNF 范式,我们可以精确理解一段 C 代码的语法结构。

核心推导过程:

  1. 翻译单元(translation_unit) → 外部声明(external_decl)
  2. 外部声明 → 函数定义(function_definition)
  3. 函数定义 → 声明说明符(int)+ 声明符(main(void))+ 复合语句({ return 0; }
  4. 声明说明符 → 类型说明符(int
  5. 声明符 → 直接声明符 → 标识符 + 参数类型列表(main + (void)
  6. 复合语句{ 声明列表 语句列表 } → 跳转语句(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),不是分隔符。每条语句必须以分号结尾:

semicolons.c
c
int a = 1;      // 赋值语句,必须以 ; 结尾
int b = 2;      // 赋值语句
return a + b;   // return 语句

这一点与 Python 等语言有根本区别。Python 使用换行来分隔语句:

python
# Python: 换行分隔语句
a = 1
b = 2

而在 C 中,换行只是空白符,不影响语法。你可以把所有代码写在一行:

one_liner.c
c
int main(void){return 0;}

NOTE

C 是自由格式语言(free-form language)。换行、缩进、空格对编译器而言仅仅是 token 分隔符。代码排版是为了人类的可读性,不是编译器的要求。

5.2 花括号:块作用域

花括号 {} 定义了一个代码块(block),也称为复合语句(compound statement)。代码块有两个作用:

  1. 语法分组:将多条语句组织为一个整体(例如函数体、if 语句体、循环体)
  2. 作用域界定:在块内定义的变量,其作用域仅限于该块
block_scope.c
c
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 都是字母/数字组成的标识符或关键字时,它们之间必须有分隔符:

whitespace_rules.c
c
int main(void){return 0;}     // 正确
intmain(void){return 0;}      // 错误!intmain 被当作一个标识符

int a = 1, b = 2;             // 正确
inta = 1, b = 2;              // 错误!inta 被当作一个标识符

规则:当两个 token 都是字母数字序列时,它们之间必须有至少一个空白符(空格、换行、制表符)或标点符号(括号、运算符等)来分隔。

可以省略的空格

c
int a=1+2;   // 运算符两侧的空格可以省略,功能完全相同
int a = 1 + 2;  // 但加上空格更易读

字符串内的空格不可省略

c
printf("hello world");   // 输出 hello world
printf("helloworld");    // 输出 helloworld(不同的字符串!)

5.4 注释:给人类看的文字

C 语言支持两种注释形式:

类型语法特点
块注释/* ... */不能嵌套,可跨越多行
行注释// ...C99 引入,到行尾结束,可以嵌套
comments.c
c
/* 这是块注释 */
// 这是行注释
/* 块注释 /* 不能嵌套 */ 这会导致编译错误 */

// 行注释可以包含 // 另一个行注释,没问题

注释可以出现在代码行的中间:

inline_comments.c
c
int x = 10;  // 行内注释:x 赋值为 10
int y = /* 块注释也可以 */ 20;  // y 赋值为 20

WARNING

块注释中不小心写入了 */ 会提前结束注释。在调试时如果需要临时「注释掉」一大段包含块注释的代码,使用 #if 0 ... #endif 是更安全的选择:

preprocessor_comment.c
c
#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 可以一步步查看每个阶段的输出:

bash
# 一步到位(最常用的方式)
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),预处理阶段几乎不做任何处理——因为没有预处理指令需要展开。但编译、汇编、链接仍然完整执行。

我们来实际看看编译过程:

bash
# 创建最简程序
$ 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 语法):

simple.s
asm
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 可以查看可执行文件依赖的动态库:

bash
$ 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, esppop ebp
  • 寄存器的保存和恢复:调用约定要求被调用者保存某些寄存器
  • 系统调用exit(0) 最终触发 exit_groupexit 系统调用
  • 内核操作:操作系统内核释放进程的虚拟内存映射、关闭所有打开的文件描述符、更新进程表、向父进程发送 SIGCHLD 信号

NOTE

你可以用 strace 追踪一个程序执行的所有系统调用:

bash
$ 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 C1978《The C Programming Language》出版,成为事实标准
C89 / C901989第一个 ISO 标准(ISO/IEC 9899:1990),32 个关键字
C951995小幅修订,增加宽字符支持
C991999增加 // 行注释、long longinline、变长数组、restrict
C112011增加 _Generic_Alignas_Atomic、多线程支持
C172017主要是缺陷修复,没有新增特性
C232023增加 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++。一个快速辨别的方法:如果代码中有 classcoutnamespacenew/delete,那就是 C++。C 使用 printfmalloc/free,没有类和命名空间。


9. #include 指令简介

到目前为止,我们的最简程序不需要任何外部函数——mainreturn 都是 C 语言内置的,不需要额外的声明。但从下一课(Lesson 02:Hello World 与 printf)开始,我们将频繁使用标准库函数,这时就需要 #include 指令。

#include 是一个预处理指令——它在编译之前由预处理器处理:

include_demo.c
c
#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 程序
simplest.c
c
int main(void)
{
    return 0;
}

这是一个完整的、符合 ISO C 标准的 C 程序。它没有输出任何文字,没有做任何计算——但它确实是一个合法的程序,编译运行后会以退出码 0 告诉操作系统:「一切顺利」。

这是你能写出的最短、最简的合法 C 程序。不要小看这短短三行代码——它包含了 C 语言的核心骨架:返回类型(int)、函数名(main)、参数列表(void)、函数体({ return 0; })、以及语句终止符(;)。每一个 C 程序,无论多么复杂,都是在这个骨架上生长出来的。

扩展:带变量与表达式的程序
variable_expression.c
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 + globalreturn 语句中被求值
  • 注释的使用:块注释 /* */ 和行注释 //

课堂讨论

  1. main 函数名是 C 语言的关键字吗?
  2. 注释可以写在某一行代码的中间吗?有哪些陷阱?
  3. 全局变量和局部变量可以重名吗?重名后会发生什么?
  4. 所有代码可以写在一行里面吗?C 语言对格式有什么要求?
  5. 把源程序中的空格去掉可以吗?哪些空格是必须的?
  6. 为什么全局变量会自动初始化为 0,而局部变量不会?

讨论答案

Q1: main 函数名是 C 语言的关键字吗?

不是。 main 只是一个约定俗成的函数名,不是 C 语言的关键字(keyword/reserved word)。

C 语言的关键字包括 intreturnifwhilefor 等——C89 共 32 个,C99 增加 5 个(inlinerestrict_Bool_Complex_Imaginary),C11 增加 _Alignas_Alignof_Atomic_Generic_Noreturn_Static_assert_Thread_local——但 main 不在其中。

为什么 main 如此特殊?

  • 链接器(linker)默认将 main 作为程序的入口点
  • 操作系统加载程序后,C 运行时(crt0)会调用 main 函数
  • 这个约定源自 Unix 和 C 语言的早期发展,是一种设计上的共识,而非语法的强制

验证方式:你可以定义一个名为 main 的变量(虽然不推荐):

main_variable_test.c
c
int main = 42;  // 合法!但程序无法正常运行(没有真正的 main 函数)

编译器不会报错,但链接器会找不到入口点 main(因为现在 main 是一个变量,不是函数),导致链接失败。

Q2: 注释可以写在某一行代码的中间吗?有哪些陷阱?

可以。 行注释 // 和块注释 /* */ 都可以出现在代码行的中间。

行注释(//:从 // 开始到行尾,之后的内容被注释掉,之前的内容正常执行。

inline_comment_demo.c
c
int x = 10;  // 这是行内注释,x 赋值为 10
int y = /* 块注释也可以 */ 20;  // y 赋值为 20

陷阱

  1. 块注释不能嵌套/* 外层 /* 内层 */ 外层继续 */ 会因为第一个 */ 就结束注释,导致「外层继续 */」被当作代码,产生编译错误
  2. 字符串中的注释符号是普通字符printf("// 这不是注释\n"); 会正常输出 // 这不是注释
  3. 块注释不能跨越预处理指令#include 等预处理指令必须在注释之外

最佳实践:行内注释应简洁,解释「为什么」而非「是什么」。代码本身应该说明「是什么」。

Q3: 全局变量和局部变量可以重名吗?重名后会发生什么?

可以重名。 当全局变量和局部变量同名时,在局部变量的作用域内,局部变量会遮蔽(shadow)全局变量

shadow_demo.c
c
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 分隔符)。

one_line.c
c
int main(void){return 0;}

上面这行代码完全合法,与多行版本等价。

但强烈不推荐! 原因:

  • 可读性极差:人类阅读代码需要视觉结构。多行缩进、空行分隔逻辑块,这些对理解代码至关重要
  • 难以调试:编译器报错时给出的行号将失去意义——所有错误都在「第 1 行」
  • 协作困难:其他人无法维护这样的代码

例外:某些场合刻意压缩代码(如代码混淆竞赛 IOCCC——International Obfuscated C Code Contest),但这是娱乐性质,不是工程实践。

Q5: 把源程序中的空格去掉可以吗?哪些空格是必须的?

大部分空格可以去掉,但有些空格是必须的。

必须保留的空格(分隔 token)

required_spaces.c
c
int main(void){return 0;}   // 正确
intmain(void){return 0;}    // 错误!intmain 被当作一个标识符

int a = 1, b = 2;   // 正确
inta = 1, b = 2;    // 错误!inta 被当作一个标识符

规则:当两个相邻的 token 都是字母/数字组成的标识符或关键字时,它们之间必须有空格(或其他分隔符,如括号、运算符)来区分。这是因为 C 的词法分析器采用「最长匹配」原则——它会尽可能多地读取字符来构成一个 token。

可以省略的空格

c
int a = 1 + 2;   // 有空格,可读
int a=1+2;       // 无空格,同样合法

字符串内的空格不可省略

c
printf("hello world");  // 输出 hello world
printf("helloworld");   // 输出 helloworld(不同的字符串!)
Q6: 为什么全局变量会自动初始化为 0,而局部变量不会?

根本原因:内存存储位置和初始化机制的差异。

全局变量(静态存储期)

  • 存储在数据段(.data,已初始化)或 BSS 段(.bss,未初始化)
  • 操作系统在加载程序时,会将 BSS 段整块清零
  • 这是 ELF 可执行文件格式的要求——BSS 段只记录大小,不占用文件空间,加载时由操作系统分配并清零
  • 因此,未显式初始化的全局变量保证为 0

局部变量(自动存储期)

  • 存储在栈上
  • 栈空间被反复使用——函数调用时分配栈帧,返回时释放
  • 新分配的栈空间包含的是上一次使用后残留的值(垃圾值)
  • 自动初始化为 0 需要编译器插入额外的清零指令(memset),这会带来运行时开销

C 语言的设计哲学:「不为你用不到的东西付费」(zero-overhead principle)。如果程序员需要初始值,可以显式初始化;如果不需要,不应强制付出性能代价。

init_cost.c
c
// 全局变量:自动清零的代价在程序启动时一次性支付
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

参考解答
exit_code_42.c
c
int main(void)
{
    return 42;
}
bash
$ 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 寄存器传递。

参考解答
sum_global_local.c
c
int g_x = 10;

int main(void)
{
    int l_y = 20;
    return g_x + l_y;  // 返回 30
}
bash
$ gcc sum_global_local.c -o sum_global_local
$ ./sum_global_local
$ echo $?    # 输出 30

练习 3:运算符优先级实验

编写一个 C 程序,计算以下表达式的值并作为返回值(每个表达式单独写一个程序),用 echo $? 验证你的预期:

  1. 1 + 2 * 3 → 预期值是多少?
  2. (1 + 2) * 3 → 预期值是多少?
  3. 10 % 3 + 5 → 预期值是多少?

知识点提示* 优先级高于 +% 优先级与 */ 相同。

参考解答
expr1.c
c
// 表达式 1:1 + 2 * 3 = 1 + 6 = 7
int main(void) { return 1 + 2 * 3; }
expr2.c
c
// 表达式 2:(1 + 2) * 3 = 3 * 3 = 9
int main(void) { return (1 + 2) * 3; }
expr3.c
c
// 表达式 3:10 % 3 + 5 = 1 + 5 = 6
int main(void) { return 10 % 3 + 5; }
bash
$ 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?为什么?

知识点提示:局部变量遮蔽全局变量。编译器从最内层作用域向外查找。

参考解答
shadow_test.c
c
int x = 100;

int main(void)
{
    int x = 200;  // 遮蔽全局变量 x
    return x;     // 返回局部变量 x,值为 200
}
bash
$ gcc shadow_test.c -o shadow_test && ./shadow_test && echo $?    # 200

结果是 200,因为局部变量遮蔽了全局变量。在 main 函数内部,编译器找到局部变量 x 就停止查找,不会继续向外找全局变量 x

练习 5(挑战):用 size 命令探查内存布局

编写两个程序:

  1. prog_data.c:包含 int global = 42;(已初始化,存数据段)
  2. prog_bss.c:包含 int global;(未初始化,存 BSS 段)

分别编译,用 size 命令对比两者的 text/data/bss 各段大小。解释为什么 prog_bss.c 的 data 段更小,而 bss 段更大。

知识点提示:已初始化的全局变量占用可执行文件的 data 段空间;未初始化的全局变量在 BSS 段只记录大小,不占用文件空间。

参考解答
prog_data.c
c
int global = 42;
int main(void) { return global; }
prog_bss.c
c
int global;
int main(void) { return global; }  // global 自动初始化为 0,返回 0
bash
$ 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.cglobal = 42 的值需要存储在可执行文件的 data 段中,因此 data 段多了 4 字节。prog_bss.cglobal 未初始化,只需要在 BSS 段记录「需要 4 字节空间」,加载时由操作系统分配并清零,不占用可执行文件空间。

练习 6(挑战):常见编译错误收集

故意在程序中引入以下错误,记录 GCC 的报错信息,理解每条错误信息的含义:

  1. 缺少分号:int main(void) { return 0 }(缺少 ;
  2. 花括号不匹配:int main(void) { return 0;(缺少 }
  3. 拼写错误:int mian(void) { return 0; }main 拼成 mian
  4. 错误的返回类型:void main(void) { return 0; }
  5. 缺少返回语句:int main(void) { }(用 -Wall -std=c89 编译)

知识点提示:编译器错误信息是程序员最重要的调试工具之一。学会阅读和理解错误信息,是编程的基本功。

参考解答
error1_missing_semicolon.c
c
int main(void) { return 0 }  // 缺少分号

GCC 报错示例:error: expected ';' before '}' token

error2_unmatched_brace.c
c
int main(void) { return 0;  // 缺少 }

GCC 报错示例:error: expected declaration or statement at end of input

error3_typo.c
c
int mian(void) { return 0; }  // main 拼错

GCC 报错示例:undefined reference to 'main'(链接阶段报错,因为找不到入口点 main

error4_void_main.c
c
void main(void) { return 0; }  // 不符合标准

GCC 报错示例(使用 -Wall -pedantic):warning: return type of 'main' is not 'int'

error5_no_return.c
c
int main(void) { }  // 没有 return(C89 模式)

GCC 报错示例(使用 -Wall -std=c89):warning: control reaches end of non-void function


参考资料


"Many of the improvements I introduced when developing C simply looked like a good thing to do." — Dennis Ritchie

Released under the MIT License.