
C 代码入门:从编写第一个程序开始
C 是一种历史悠久、至今仍在广泛使用的通用编程语言。它让程序员能够直接操作内存和底层数据,同时也提供函数、结构体等组织代码的工具。初学 C 的关键不只是记住语法,还要理解数据的边界、内存的归属,以及编译器究竟如何把源代码变成可执行程序。
本文从一个能运行的程序开始,逐步介绍编译、基本语法、函数、数组和指针,并说明适用场景与常见错误。示例以 ISO C11 为目标,尽量使用标准 C 接口。代码块中的内容可以复制为独立的 .c 文件编译;如果示例注明需要前置代码,则按说明将代码放入对应程序中。
一、C 适合做什么
C 常见于操作系统内核、设备驱动、嵌入式固件、编译器、数据库、网络协议实现,以及对运行时和资源有明确要求的底层组件。它的优势包括执行开销较低、运行环境选择多、与硬件和操作系统接口紧密,也便于和其他语言通过接口互操作。
这些优势并不意味着 C 一定比其他语言更快,也不意味着所有程序都适合用 C 写。实际性能取决于算法、数据布局、编译选项和运行环境。对于常规网站、业务应用或数据处理任务,具有自动内存管理和更丰富标准库的语言往往更容易开发与维护。选择 C 的理由应当是项目确实需要它的控制能力、运行环境或互操作能力,而不是单纯认为“底层语言一定更高效”。
C 给程序员带来自由,也把不少责任交给程序员:数组越界、错误的指针使用、整数溢出等问题可能造成未定义行为。C 本身不会自动检查每一次内存访问,也不会替程序决定输入是否可信。因此,编写 C 程序时要主动检查边界,并利用编译器警告、测试和动态分析工具。
二、安装编译器并运行第一个程序
编译器负责把 C 源文件翻译成目标程序。常见选择包括 GCC、Clang 和 Microsoft Visual C(MSVC)。Linux 通常可以通过系统包管理器安装 GCC 或 Clang;macOS 可安装 Xcode Command Line Tools,其中包含 Clang;Windows 可安装 Visual Studio 的 C++ 工作负载以获得 MSVC,也可以使用 MinGW-w64 或基于 LLVM 的工具链。不同平台的安装方式和可用命令不完全相同。
新建文件 hello.c,输入以下完整程序:
#include <stdio.h>
int main(void)
{
printf("Hello, C!\n");
return 0;
}
#include <stdio.h> 引入标准输入输出库的声明,使程序可以调用 printf。程序从 main 函数开始执行;int 表示它返回一个整数,void 表示这里没有声明命令行参数。printf 将格式化文本写到标准输出,\n 是换行符。按照常见约定,返回 0 表示程序成功结束,非零值通常表示发生了错误。
使用 GCC 或 Clang 时,可以在源文件所在目录运行:
cc -std=c11 -Wall -Wextra -Wpedantic hello.c -o hello
这里的 cc 通常指系统配置的 C 编译器;也可以替换为 gcc 或 clang。-std=c11 选择 C11 语言标准,-Wall、-Wextra 和 -Wpedantic 启用一组有助于发现问题的警告。警告不等于错误,但初学时应尽量逐条理解并修复,而不是习惯性忽略。
在 Linux 或 macOS 上,可执行程序通常这样运行:
./hello
在 Windows 的命令提示符或 PowerShell 中,输出文件可能是 hello.exe,可运行 .\hello.exe。MSVC 的编译命令和项目设置不同,应在相应的开发者命令行环境中使用其文档支持的选项。编译成功只说明编译器生成了程序,并不说明程序逻辑正确。
三、变量、类型与表达式
C 是静态类型语言:变量在声明时就有类型,编译器会据此解释数据及运算。常用类型包括整数类型 int、单精度浮点类型 float、双精度浮点类型 double 和字符类型 char。C 标准并不规定这些类型在所有平台上必须采用完全相同的位数;例如 int 的具体范围由实现决定。需要精确宽度时,可考虑标准头文件 <stdint.h> 提供的类型,但某些精确宽度类型只在平台确实支持时才定义。
#include <stdio.h>
int main(void)
{
int count = 3;
double price = 12.5;
char grade = 'A';
printf("数量:%d\n", count);
printf("单价:%.2f\n", price);
printf("等级:%c\n", grade);
return 0;
}
格式说明符必须和传入的参数类型匹配。上例中,%d 对应 int,%f 用于格式化输出浮点数,%c 用于输出字符;%.2f 表示小数点后显示两位。格式与类型不匹配时,结果可能不符合预期,某些情况下还会造成未定义行为。不要把 printf 的格式字符串当作普通注释来随意填写。
C 中单引号和双引号含义不同。'A' 是字符常量,"A" 是以空字符结尾的字符串。表达式也遵循类型规则:例如整数相除会舍弃小数部分,5 / 2 的结果是 2。如果需要小数结果,应让运算涉及浮点数,如 5.0 / 2。
四、条件、循环与函数
条件语句用于根据表达式选择不同分支;循环用于重复执行代码。以下程序读取一个整数并输出它是正数、负数还是零。为了避免把输入失败误当成有效数据,程序会检查 scanf 的返回值:
#include <stdio.h>
int main(void)
{
int value;
printf("请输入一个整数:");
if (scanf("%d", &value) != 1) {
fprintf(stderr, "输入不是有效整数。\n");
return 1;
}
if (value > 0) {
puts("这是正数。");
} else if (value < 0) {
puts("这是负数。");
} else {
puts("这是零。");
}
return 0;
}
scanf 的 %d 需要一个 int 对象的地址,所以这里使用 &value。它的返回值表示成功匹配并赋值的项目数量;此处要求恰好为 1。若输入包含超出 int 可表示范围的数字,不能只依靠这个检查来处理所有边界情形。实际项目中,文本输入通常更适合先用 fgets 读取一行,再用 strtol 等函数解析,并检查错误、尾随内容和数值范围。
下面用 for 循环计算从 1 到 5 的和:
#include <stdio.h>
int main(void)
{
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum += i;
}
printf("总和:%d\n", sum);
return 0;
}
for 括号中的三部分依次负责初始化、循环条件和每轮更新。i++ 相当于让 i 增加 1。需要注意的是,有符号整数超出其可表示范围会产生未定义行为;如果循环计数或求和可能很大,应选用能容纳所需范围的类型,并在计算前设计溢出检查,而不是假定整数会自动变大。
函数可以把一段有明确职责的逻辑封装起来。下面的函数接收两个整数并返回较大的一个:
#include <stdio.h>
int max_int(int a, int b)
{
return (a > b) ? a : b;
}
int main(void)
{
printf("%d\n", max_int(8, 5));
return 0;
}
函数返回类型、函数名和参数列表共同构成函数声明的一部分。把函数放在调用它的代码之前,编译器就能在调用处看到定义;在较大的程序中,通常会把函数声明放进头文件,把函数定义放进源文件。函数参数默认按值传递:函数收到的是参数值的副本。想让函数修改调用者的对象时,通常需要传入对象的指针。
五、数组和字符串
数组用于存放多个相同类型的元素。数组下标从 0 开始,长度为 n 的数组合法下标是 0 到 n - 1。C 不会在普通数组访问时自动检查下标;越界读写会导致未定义行为。
#include <stdio.h>
int main(void)
{
int scores[4] = {82, 91, 76, 88};
int total = 0;
for (int i = 0; i < 4; i++) {
total += scores[i];
}
printf("平均分:%.2f\n", total / 4.0);
return 0;
}
这里 scores 有 4 个元素,所以循环条件写成 i < 4,而不是 i <= 4。在函数中把数组作为参数传递时,形参通常会调整为指针;函数本身通常无法仅靠该形参获知原数组长度。因此,常见做法是同时传入数组指针和元素数量,并明确数量的单位是元素个数还是字节数。
C 字符串不是独立的字符串类型,而是以空字符 '\0' 结尾的字符数组。存放字符串时,数组容量必须包含结尾的空字符。例如,char name[6] 最多容纳 5 个普通字符和一个结尾空字符。处理来自用户或网络的字符串时,要检查长度,不能把输入直接写入容量未知或不足的缓冲区。
#include <stdio.h>
int main(void)
{
char name[32];
printf("请输入名字:");
if (fgets(name, sizeof name, stdin) == NULL) {
fprintf(stderr, "读取失败。\n");
return 1;
}
printf("你好,%s", name);
return 0;
}
fgets 最多读取数组容量减 1 个字符,并在成功读取后写入结尾空字符;如果读到换行符,也会把换行符保留在数组中。因此上例输出时不额外添加换行。若输入行长于缓冲区,剩余部分还留在输入流中,程序需要根据需求继续读取、清理或明确拒绝过长输入。调用字符串处理函数前,也要保证数据确实以空字符结尾。
六、指针与内存边界
指针保存对象的地址。运算符 & 用于取得对象地址,* 在指针有效时用于访问它指向的对象。指针让函数能够修改调用者的变量,也常用于数组、动态内存和系统接口;与此同时,错误指针操作是 C 程序问题的重要来源。
#include <stdio.h>
void add_one(int *value)
{
if (value != NULL) {
*value += 1;
}
}
int main(void)
{
int count = 4;
add_one(&count);
printf("%d\n", count);
return 0;
}
int *value 表示指向 int 的指针。调用 add_one(&count) 将 count 的地址传入,函数通过 *value 修改原对象。检查空指针只能处理“没有指向对象”的情况,不能证明非空指针一定有效。使用未初始化指针、访问已经释放的对象、对越界地址解引用,都可能导致未定义行为。
不要把局部变量的地址返回给调用者:函数返回后,该局部对象的生命周期已经结束,得到的地址不再能安全使用。还要区分对象的生命周期和指针变量自身的生命周期:指针变量仍在,并不代表它指向的对象仍然存在。设计接口时要说明指针是否可以为空、数据由谁分配和释放,以及指针有效到什么时候。
七、动态内存的基本做法
需要在运行时申请大小可变的内存时,可以使用 malloc、calloc 和 realloc,并在不再需要时调用 free。这些函数声明在 <stdlib.h> 中。申请可能失败,返回值要检查;内存释放后不能继续使用对应指针,也不应对同一块已释放内存重复调用 free。
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
size_t count = 5;
int *values = malloc(count * sizeof *values);
if (values == NULL) {
fprintf(stderr, "内存分配失败。\n");
return 1;
}
for (size_t i = 0; i < count; i++) {
values[i] = (int)(i + 1);
}
printf("最后一个数:%d\n", values[count - 1]);
free(values);
values = NULL;
return 0;
}
sizeof *values 表示一个元素所占的字节数,比重复写类型名更不容易在修改类型时出错。此示例的 count 是固定的小值;若数量来自外部输入,必须先检查数量是否合法,并在计算 count * sizeof *values 前确认乘法不会超出 size_t 可表示范围,否则申请的空间可能小于预期。释放后将指针设为 NULL 有助于减少误用,但不会自动修复程序中其他副本保存的旧地址。
八、命令行参数和文件
程序可以通过 main 的参数接收命令行输入:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("参数数量:%d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
argc 是参数数量,argv 是指向参数字符串的指针数组。通常 argv[0] 表示程序名,其具体内容由运行环境决定;参数个数和总长度并没有一个可供所有系统通用的固定限制,实际限制取决于操作系统和运行环境。程序应检查 argc 后再访问相应下标,并对参数内容和长度进行验证,不能假设用户提供的参数可信。
读取文件时,也要检查文件是否成功打开、读写是否完成,以及最后是否需要关闭文件。文件路径可能无效,权限可能不足,磁盘空间也可能耗尽。输入、文件和网络数据都应视作外部数据:在用于数组下标、内存分配大小、格式化输出或系统调用之前,先验证格式、范围和长度。
九、常见错误与排查办法
- 数组越界:把长度为
n的数组误认为下标可以到n。牢记合法范围是0到n - 1,并在接口中传递长度。 - 未初始化变量:自动存储期的局部变量若没有初始化,其值不可直接当作有效数据使用。声明时赋予明确初值,或者确认所有控制路径都完成赋值。
- 把
=写成==,或反过来:=是赋值,==是比较。开启编译器警告,并仔细阅读条件表达式。 - 格式字符串不匹配:例如用不合适的格式符输出变量,或忘记传入指针。根据函数文档核对每个格式说明符和实参类型。
- 忽略输入失败:输入函数可能遇到文件结束或格式不符。检查返回值;对复杂输入优先采用有容量限制的读取方式并单独解析。
- 使用危险的字符串写入方式:不要使用没有目标缓冲区容量参数的
gets,它已从现代 C 标准中移除,无法安全限制输入长度。使用fgets等能表达容量上限的接口,并处理截断情况。 - 错误使用指针或动态内存:检查分配结果,明确谁负责释放;不要解引用空指针、悬空指针或越界指针,不要重复释放。
- 把编译通过当作正确:编译器能发现不少语法问题和部分可疑代码,但不能证明程序没有逻辑错误、越界或并发问题。测试时覆盖边界输入,并尽可能使用静态分析和运行时检查。
在 GCC 或 Clang 上,开发阶段可以尝试加入 AddressSanitizer 和 UndefinedBehaviorSanitizer(支持情况取决于编译器与平台):
cc -std=c11 -Wall -Wextra -Wpedantic -g \
-fsanitize=address,undefined hello.c -o hello
这类工具有助于在测试时发现部分越界访问、释放后使用和未定义行为,但不能覆盖所有错误,也不能替代代码审查和输入验证。正式构建是否启用、如何部署,应根据目标平台和项目要求决定。
十、关于参数限制与安全用途
C 标准定义了语言行为和一些实现需要遵守的最低能力要求,但并不为所有类型、数组、命令行参数或可用内存设定一个适用于所有设备的实际固定上限。许多具体边界由实现、操作系统、编译选项和运行环境决定。遇到“最多能处理多少项”“指针一定占多少字节”这类问题,应查阅目标平台与编译器文档,并在程序中显式检查,而不要把某台机器上的观察当成语言保证。
C 广泛用于安全相关软件,并不意味着用 C 编写的程序天然安全,也不意味着 C 适合任何安全敏感任务。语言本身不会自动阻止多种内存错误,安全性依赖正确的设计、实现、编译、审查、测试和部署。处理不可信数据时,应实施长度和范围校验,限制资源消耗,避免不必要的手动内存管理,并考虑项目要求所允许的安全编码规范与分析工具。
若项目明确需要 C,可通过严格的警告选项、代码审查、边界测试、模糊测试和静态分析降低风险;具体措施要结合目标平台与威胁模型选择。对许多应用而言,优先选择能减少内存管理责任的语言,或仅把经过审查的 C 代码限制在必要的底层边界,也是一种合理的工程取舍。
总结:从小程序建立正确习惯
掌握 C 入门语法,可以从编写、编译并运行小程序开始,再逐步练习条件、循环、函数、数组和指针。每学一个新概念,都要同时问三个问题:数据的类型和有效范围是什么?对象什么时候仍然有效?输入、长度和操作结果有没有经过检查?
先使用清晰的小函数和固定边界的示例,再逐步接触文件、动态内存和系统接口。开启编译器警告,检查函数返回值,避免数组越界和不安全的字符串处理,并在运行测试中利用合适的分析工具。这样学到的不只是 C 的语法,也是在手动管理资源时必须具备的工程习惯。




