1.3 第一个程序
1.3 第一个程序
hello, world 到这一节为止,你已经打出来两遍了:1.1 在终端里用命令写、用 gcc 编译;1.2 在 VS Code 里点按钮跑。但两次都是照抄。
这一节把这四行拆开:每一行写的是什么、为什么必须在这、少写会怎样。目标是——不看任何模板,从空文件敲出这四行,并且知道自己在敲什么。
配套视频:C 语言从零开始 03|四行代码逐行拆(发布后补链接,和下面的步骤一一对应)
学完这一节,你应该能够
第 1 步 · 四行代码,逐行拆
进入 D:\C_Code(1.1 建的),再打开 hello.c。全文只有四行代码,外面包一对花括号——{ 跟在 main 那一行末尾,} 单独占一行:
#include <stdio.h>
int main(void) {
printf("hello, world\n");
return 0;
}| 行 | 代码 | 它干的事 | 一句话记住 |
|---|---|---|---|
| 1 | #include <stdio.h> | 把 printf 等函数的声明引进来 | 借来 printf 的说明书 |
| 2 | int main(void) { … } | 定义 main 函数,并用花括号圈出函数体 | 系统从这扇门进来 |
| 3 | printf("hello, world\n"); | 把字符串写到标准输出(默认是屏幕) | 唯一真正干活的一行 |
| 4 | return 0; | 把 0 交给系统 | 向系统交差 |
① #include <stdio.h> —— 借来说明书
stdio 这个名字取自 std(standard,标准)+ io(input/output,输入输出);printf 只是 stdio.h 声明的众多函数之一。
这里要分清两件事:头文件里通常只有两类东西——声明(函数叫什么、收什么参数、返回什么)和宏(EOF、NULL 这类);函数的实现(真正干活的机器码)在 C 运行库里,编译器不需要去读它:编译时只按声明生成一次调用,机器码由链接器在最后一步从运行库里接上。
# 开头的行叫预处理指令,由预处理器执行。预处理是编译的第一个阶段——GCC 把预处理器做进了 gcc 这一个程序里,所以命令行上看不到它单独出现。
#include <stdio.h> 的效果,标准里的措辞是「如同该文件的内容出现在这条指令所在的位置」。实际比「搬一个文件」复杂三层:
- 尖括号
<...>表示去系统头文件目录里找;双引号"..."先找当前文件所在目录,找不到再去系统目录(这是 GCC 的实际行为,C 标准只说两种写法的查找方式由实现定义); stdio.h自己还会#include别的头文件——所以最后进来的是一棵树,不是一个文件;- 这一步没有导入任何库。
printf的机器码在 C 运行库里,要到最后一步链接才接上。
说到底,#include 是文本替换,不是导入库。
② int main(void) —— 程序从这扇门进来
一个 exe 里可以有很多函数,但你自己写的代码从 main 开始执行。
严格一点说:操作系统加载 exe 后跳到的是文件头里登记的入口点,那是 C 运行库的一段启动代码;它做完初始化,再调用 main。所以「系统只认 main」是个简化说法——系统认的是入口点,main 是启动代码要调用的那个函数。本节不需要记住这一层,但讲退出码时会再提一次。
int是main返回值的类型,这个值就是第 4 行那个0;void表示它不收参数;- 名字必须是
main:启动代码要调用一个叫main的函数。写成mian,编译这一步能过(gcc -c会正常产出目标文件),但链接会失败:报错里说的是undefined reference to 'WinMain',看着跟main没关系,其实就是在说「找不到入口」;后面还会跟一行collect2.exe: error: ld returned 1 exit status。不同 MinGW 版本措辞略有差异,认准undefined reference就行。
{ 是函数体的开始,与最后一行的 } 配对。花括号本身不执行任何东西,它只是圈定「这几条语句属于 main」。
③ printf("hello, world\n"); —— 唯一真正干活的一行
printf 是函数,括号里的第一个实参是格式串。它在运行时只认一件事:% 开头的占位符(%d 整数、%s 字符串),遇到就换成后面参数的值;其余的字符原样送出去。这一句里没有 %,所以看起来就是「原样打印」。
\n 是转义序列,代表换行符(值 0x0A)。注意它跟 printf 没关系——这是编译器处理字符串字面量的规则:源码里占两个字符(反斜杠和 n),编译后进 exe 的是一个字节 0x0A。删掉它,光标会停在 hello, world 后面。
行尾的分号是语句结束符。C 里换行和缩进都只是空白字符、不影响语义,语句的边界由分号决定——所以一行可以拆成几行写,几行也能挤成一行。换行真正有意义的地方只有几处:// 注释到行尾结束;/* */ 可以跨行;预处理指令到行尾结束(末尾加反斜杠可以续行);普通字符串字面量里不能直接换行。
④ return 0; —— 向系统交差
return 结束当前函数,并把后面的值交给调用者。main 的调用者是 ② 里那段启动代码,它拿到这个整数后交给操作系统——这就是退出码(exit code)。
约定:0 表示成功,非 0 表示失败。这个约定来自 Unix,今天几乎所有命令行工具、脚本和 CI 都遵守它。第 5 步会让你亲眼看到这个数字。
一个容易忽略的规则
C99 起,main 执行到最后的 } 却没写 return,等价于 return 0。所以严格说 return 0 可以省。
第 2 步 · 编译:一条命令,没有消息就是好消息
本教程统一用下面这个格式,后面所有编译命令都是它的最小版:
gcc hello.c -o hello后面的章节会在这个基础上加 -std=c17 -Wall -Wextra(标准版本与警告);本节先不加,是为了让报错信息保持干净。
| 部分 | 意思 |
|---|---|
gcc | 编译器 |
hello.c | 输入:源文件 |
-o | output 的缩写(是小写字母 o,不是数字 0),后面紧跟产物名 |
hello | 输出:可执行文件名,Windows 上 MinGW 会自动补上 .exe |
按下回车,通常屏幕上什么都不会发生。
编译器遵循「没有消息就是好消息」:只在出问题时说话,这是 Unix 传下来的习惯。所以「没输出」不等于「没运行」,它只说明没报错。
真正的判据是 gcc 自己的退出码——gcc 也是一个程序,遵守同一个约定:
gcc hello.c -o hello
echo $LASTEXITCODE看到 0 才算成功(这个数字第 5 步会讲来历)。别只看 hello.exe 在不在:上一次编译的产物还躺在目录里,这次失败了照样能看到它。要看产物有没有更新,就比时间:
ls hello.exe | Select-Object LastWriteTime这也是新手最常误判的一步——很多人以为卡住了,把命令又敲了一遍。
为什么一律写 -o
不写 -o,gcc 会把产物命名成 a.exe(Linux 上是 a.out)。这个名字说明不了它是谁编译出来的;目录里放两个程序,后编的那个会直接盖掉前一个,而且不给你任何提示。带上 -o 之后,产物名字和源文件对得上,重编也只是覆盖自己。所以本教程所有命令都带 -o。
第 3 步 · 运行:.\hello
.\hello屏幕上会打出:
hello, world.\ 的规矩 1.1 里提过要加,这里说清原因:PowerShell 不会在当前目录里找程序(cmd 会,这是两者的一处差别),.\ 是明确告诉它「就这个目录里的这个文件」。这不是 C 的规矩,是 shell 的规矩。
第 4 步 · 改一个字符,看它变
把 hello, world 改成 hello, C,Ctrl+S 保存,然后直接运行:
.\hello输出的还是 hello, world。再编译一次,然后运行:
gcc hello.c -o hello
.\hello这次才变成 hello, C。
原因:exe 是源代码在编译那一刻的快照。源码和 exe 之间没有任何活的联系——你改源码,exe 不会自己更新,就像改了菜谱,已经做好的那盘菜不会跟着变。
用 VS Code 那个按钮的人一般碰不到这个坑:按钮每次都会先编译再运行,编译不通过就不会往下跑。而手敲命令的人迟早会遇上,「我明明改了」的九成原因就是忘了重新编译。
第 5 步 · 退出码:return 0 到底交给了谁
运行完,紧接着看 PowerShell 记下的「上一个外部程序交回来的值」:
.\hello
echo $LASTEXITCODE输出 0。
现在回到代码,把 return 0 改成 return 1,保存、重新编译、再跑一遍上面两条命令,echo $LASTEXITCODE 会变成 1。看完记得改回 0。
谁在看这个数字
shell、批处理脚本、CI 流水线都靠它判断「上一步成没成」。所以 0 = 成功 不是随便定的,它是程序与外部世界约定的成败信号——值得显式写出来,而不是留给默认值。
常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
敲完 gcc 没有任何输出 | 没有报错;编译器只在出错时说话 | 正常。想确认就 echo $LASTEXITCODE 应为 0,或看 hello.exe 的修改时间 |
.\hello 提示找不到命令 | 还没编译,或当前目录不是 hello.c 所在目录 | 先编译;用 pwd 确认位置,cd 回 D:\C_Code |
产物叫 a.exe 而不是 hello.exe | 命令里漏了 -o hello | 补上,删掉多余的 a.exe |
| 双击 exe 窗口一闪而过 | 程序跑完就退出,窗口跟着关了,不是崩溃 | 在终端里运行才能看到输出 |
| 输出中文是乱码 | 中文版 Windows 的控制台默认按代码页 936(GBK)解码,而 gcc 默认按 UTF-8 把字符串写进 exe | 先确认源文件本身存成了 UTF-8(VS Code 右下角看编码),再编译时加 -fexec-charset=GBK(1.2 的 tasks.json 里已经加过) |
| 改了代码,输出没变 | 没重新编译 | 再跑一次 gcc hello.c -o hello |
echo $LASTEXITCODE 是空的或没变 | 它只由外部程序(gcc、hello.exe 这类原生 exe)更新;PowerShell 自己的命令不碰它,出错时改的是 $? | 先运行 .\hello,再 echo |
术语小词典
| 词 | 一句话解释 |
|---|---|
| 源文件 | 你写的 .c 文本,给人看的 |
| 可执行文件 | 编译器产出的 .exe,给机器执行的 |
| 链接 | 把编译产物和 C 运行库接成一个 exe 的步骤;printf 的机器码这时才进来 |
| 标准输出 | 程序默认的输出通道;在终端里运行就显示在屏幕上,重定向时可以写进文件 |
| 预处理 | 编译的第一个阶段:执行 # 指令、展开宏。注释在更早的一步就被换成一个空格。整份文件都会过一遍,不只是 # 开头的行 |
| 头文件 | .h 结尾的文件,通常只放声明和宏:函数原型、FILE 这类类型、EOF 这类宏 |
| 入口函数 | main,你自己的代码开始执行的地方;系统先跑启动代码,再由它调用 main |
| 语句 | 程序里的一条完整动作。表达式语句和 return、break 这类跳转语句以分号结束;if、while 的语句体和函数体用一对花括号包起来 |
| 转义序列 | 反斜杠开头的组合,表示看不见或打不出的字符:\n 换行、\t 制表、\\ 反斜杠本身 |
| 格式串 | printf 的第一个实参,里面的 % 占位符会被后面的参数替换 |
| 实参 | 调用函数时括号里真正传进去的值 |
| 退出码 | 程序结束时交给调用者的整数,0 表示成功 |
练习
- 合上讲义,从空文件默写这四行代码(连那对花括号),编译运行通过。
- 把字符串换成你的名字,再加一行
printf输出一句中文(先确认文件存的是 UTF-8)。乱码就加-fexec-charset=GBK,自己验证一次。 - 删掉
\n,观察输出与终端提示符的位置,再把\n加回去。 - 把
return 0改成return 1,用echo $LASTEXITCODE看差别,最后改回0。 - 故意不写
-o,看看产物叫什么名字。