C 语言入门:写给新手的核心语法、指针与避坑指南
- 约 15 分钟读完 - 全文 5823 字C 语言入门:写给新手的核心语法、指针与避坑指南
参考书籍:《C Primer Plus》(Stephen Prata 著)
目标读者:刚开始学 C,或者学过一点但总觉得「指针搞不懂」「代码总崩溃」的同学。
C 语言是一门很「老实」的语言:它不会帮你做太多事,但也不会藏太多秘密。学会 C 之后,你再学 Python、Java、Go 甚至 Rust,都会有一种「底层原来是这样」的通透感。
这篇文章不讲完所有细节(那是 700 页书做的事),而是帮你建立主干知识框架:
- C 程序到底是怎么跑起来的?
- 变量、类型、控制流、函数这些核心语法怎么用?
- 指针到底是什么?为什么那么多人觉得难?
- 写 C 时最容易踩哪些坑,怎么避开?
- 学到什么程度可以开始刷算法、看操作系统?
读完之后,你应该能独立写出几百行的小程序,也能看懂大部分基础算法题里的 C 代码。
一、C 程序是怎么跑起来的
在写代码之前,先搞清楚一件最重要的事:C 是一门编译型语言。
flowchart LR
A["源代码<br/>hello.c"] -->|"编译器 gcc/clang"| B["目标文件<br/>hello.obj"]
B -->|"链接器"| C["可执行文件<br/>hello.exe / a.out"]
C -->|"操作系统加载"| D["CPU 执行"]
你写的 .c 文件只是文本。编译器会把它翻译成机器能懂的二进制指令,链接器再把多个文件拼起来,最后操作系统把可执行文件加载进内存,CPU 一条条执行。
这个流程决定了 C 的很多特点:
- 快:编译后直接跑机器码,没有解释器中间层。
- 贴近硬件:你可以直接操作内存地址。
- 容易出错:编译器只检查语法,运行时的内存问题(比如访问越界)它不管。
二、环境搭建与第一个程序
2.1 安装编译器
Windows:推荐安装 MSYS2,然后执行:
pacman -S mingw-w64-x86_64-gcc或者安装 Dev-C++、Code::Blocks 等集成环境。
macOS:
xcode-select --installLinux:
sudo apt install gcc2.2 第一个程序
创建 hello.c:
#include <stdio.h>
int main(void) {
printf("Hello, C!\n");
return 0;
}编译运行:
gcc hello.c -o hello
./hello2.3 程序的基本结构
flowchart TD
A["头文件包含<br/>#include"] --> B["全局声明/宏定义"]
B --> C["main 函数"]
C --> D["变量声明"]
D --> E["语句与表达式"]
E --> F["return 0;"]
#include <stdio.h>:把标准输入输出库的声明引进来,printf才能用。int main(void):程序的入口函数,返回值是int。return 0;:告诉操作系统,程序正常结束。
小坑:很多新手写
main()不带void,虽然很多编译器允许,但标准写法是int main(void)。
三、核心语法
3.1 变量与数据类型
C 是强类型静态语言:每个变量必须先声明类型,类型在编译时就确定了。
int age = 20; // 整数,通常 4 字节
float pi = 3.14f; // 单精度浮点数
double e = 2.71828; // 双精度浮点数
char grade = 'A'; // 字符,1 字节
_Bool flag = 1; // C99 内置布尔类型;若用 bool/true/false 才需要 <stdbool.h>
常见类型占用(64 位系统常见情况):
| 类型 | 典型大小 | 说明 |
|---|---|---|
char |
1 字节 | 存字符或小整数 -128~127 |
short |
2 字节 | 短整数 |
int |
4 字节 | 最常用整数 |
long |
4/8 字节 | 平台相关 |
long long |
8 字节 | 大整数 |
float |
4 字节 | 单精度浮点 |
double |
8 字节 | 双精度浮点 |
避坑:不要假设
int一定是 4 字节。需要精确大小时用<stdint.h>里的int32_t、uint64_t等。
3.2 运算符
int a = 10, b = 3;
int c = a / b; // 3,整数除法会截断小数
int d = a % b; // 1,取余
a++; // 后置自增
++a; // 前置自增
a += 5; // 等价于 a = a + 5
关系与逻辑运算符:==、!=、<、>、&&、||、!
大坑:
if (a = 5)会把 5 赋给 a,然后判断为真。判断相等一定要写if (a == 5)。
3.3 控制流
if / else
int score = 85;
if (score >= 90) {
printf("A\n");
} else if (score >= 80) {
printf("B\n");
} else {
printf("C or below\n");
}switch
int day = 3;
switch (day) {
case 1: printf("Mon\n"); break;
case 2: printf("Tue\n"); break;
case 3: printf("Wed\n"); break;
default: printf("Other\n"); break;
}坑:
case后面记得加break,否则会「穿透」到下一个 case。
循环
// for 循环,适合已知次数
for (int i = 0; i < 10; i++) {
printf("%d ", i);
}
// while 循环,适合条件驱动
int n = 0;
while (n < 10) {
n++;
}
// do-while,至少执行一次
do {
printf("once\n");
} while (0);3.4 函数
函数是 C 程序的基本组织单位。
#include <stdio.h>
// 函数声明:告诉编译器函数长什么样
int add(int a, int b);
int main(void) {
int result = add(3, 5);
printf("%d\n", result);
return 0;
}
// 函数定义
int add(int a, int b) {
return a + b;
}参数传递是值传递:函数里修改形参,不会影响外面的实参。
void swap_wrong(int a, int b) {
int tmp = a;
a = b;
b = tmp; // 这里的 a、b 只是副本,外面没变
}如果想修改外面的变量,就要用指针(后面会讲)。
3.5 数组与字符串
数组是连续内存中相同类型元素的集合。
int arr[5] = {1, 2, 3, 4, 5};
printf("%d\n", arr[0]); // 1
// 遍历
for (int i = 0; i < 5; i++) {
printf("%d ", arr[i]);
}C 没有真正的字符串类型,字符串是以 \0 结尾的字符数组。
char s1[] = "hello"; // 实际占 6 字节:h e l l o \0
char s2[6] = "hello";
printf("%s\n", s1);大坑:
char s[5] = "hello";会溢出!因为"hello"需要 6 个字节(包括\0)。
字符串操作常用 <string.h>:
#include <string.h>
strlen(s); // 字符串长度(不含 \0)
strcpy(dst, src); // 复制字符串,dst 必须足够大
strcat(dst, src); // 拼接字符串
strcmp(a, b); // 比较字符串
避坑:
strcpy、strcat不检查目标缓冲区大小,容易导致缓冲区溢出。更安全的是strncpy、strncat,但它们也有自己的坑(不保证结尾有\0)。
3.6 结构体
结构体把不同类型的数据打包在一起。
struct Student {
char name[20];
int age;
float score;
};
struct Student stu = {"Tom", 20, 89.5};
printf("%s: %.1f\n", stu.name, stu.score);可以用 typedef 简化:
typedef struct {
int x;
int y;
} Point;
Point p = {3, 4};四、指针:C 语言的灵魂
指针是 C 最难也最重要的概念。很多人学不懂,是因为一开始就陷入语法细节。我们先从直觉上理解:指针就是地址。
4.1 变量在内存中是什么样子
每个变量都住在内存的某个「房间」里,房间有门牌号(地址)。
flowchart LR
subgraph 内存["内存"]
A["地址 0x1000<br/>值 20<br/>变量 age"]
B["地址 0x1004<br/>值 3.14<br/>变量 pi"]
C["地址 0x100C<br/>值 'A'<br/>变量 grade"]
end
指针变量存的就是这个门牌号。
4.2 指针的基本语法
int age = 20;
int *p = &age; // & 取地址,p 指向 age
printf("%d\n", age); // 20
printf("%p\n", p); // age 的地址
printf("%d\n", *p); // 解引用,得到 20
flowchart LR
A["变量 age<br/>值:20<br/>地址:0x1000"] -->|"&age"| B["指针 p<br/>值:0x1000"]
B -->|"*p"| A
拆解一下:
int *p:声明 p 是一个指向 int 的指针。&age:拿到 age 的地址。*p:解引用,通过地址找到对应的值。
注意:
*在声明时和在使用时含义不同。声明时它表示「这是一个指针」,使用时它表示「取这个地址里的值」。
4.3 指针为什么能修改函数外的变量
前面说过,C 函数参数是值传递。如果想在函数里修改外部变量,就传指针:
void swap(int *a, int *b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(void) {
int x = 3, y = 5;
swap(&x, &y);
printf("x=%d, y=%d\n", x, y); // x=5, y=3
return 0;
}flowchart LR
subgraph 调用前
X["x=3 @0x1000"]
Y["y=5 @0x1004"]
end
subgraph swap函数内
A["a=0x1000"] --> X
B["b=0x1004"] --> Y
end
subgraph 调用后
X2["x=5 @0x1000"]
Y2["y=3 @0x1004"]
end
4.4 指针与数组
数组名在大多数表达式里会退化成指向首元素的指针。
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // 等价于 p = &arr[0]
printf("%d\n", arr[0]); // 10
printf("%d\n", *p); // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", p[1]); // 20,等价于 *(p+1)
flowchart LR
A["arr[0]<br/>10<br/>地址 0x1000"] --> B["arr[1]<br/>20<br/>地址 0x1004"]
B --> C["arr[2]<br/>30<br/>地址 0x1008"]
C --> D["arr[3]<br/>40<br/>地址 0x100C"]
D --> E["arr[4]<br/>50<br/>地址 0x1010"]
P["p = 0x1000"] --> A
数组和指针的关系很密切,但它们不是一回事:
sizeof(arr)是整个数组的大小(20 字节)。sizeof(p)是指针本身的大小(8 字节在 64 位系统)。- 数组名是常量地址,不能赋值;指针变量可以重新指向别处。
4.5 指针运算
指针加减不是简单地加减数字,而是按指向类型的大小移动。
int *p = arr; // p = 0x1000
p++; // p = 0x1004,跳过一个 int
char *cp = (char *)arr;
cp++; // cp = 0x1001,跳过一个 char
4.6 动态内存分配
程序运行时向系统申请内存,用 <stdlib.h> 里的函数。
#include <stdlib.h>
int *arr = malloc(5 * sizeof(int)); // 申请 5 个 int 的空间
if (arr == NULL) {
printf("内存申请失败\n");
return 1;
}
for (int i = 0; i < 5; i++) {
arr[i] = i * 10;
}
free(arr); // 用完必须释放
arr = NULL; // 避免悬空指针
flowchart TD
A["malloc 申请内存"] --> B["使用内存"]
B --> C["free 释放内存"]
C --> D["指针置 NULL"]
大坑:
malloc后忘记free→ 内存泄漏。free后继续使用指针 → 使用已释放内存(use-after-free)。free同一个地址两次 → 重复释放。
4.7 指针的指针
指针变量自己也有地址,所以可以有指向指针的指针。
int x = 10;
int *p = &x;
int **pp = &p;
printf("%d\n", **pp); // 10
flowchart LR
PP["pp<br/>值:0x2000"] --> P["p @0x2000<br/>值:0x1000"]
P --> X["x @0x1000<br/>值:10"]
二级指针常用于函数中修改指针本身的指向,比如链表操作、动态二维数组等。
五、C 与算法基础
C 语言很适合练习算法,因为它只提供最基本的工具,你必须自己管理数据。
5.1 算法学习的推荐路线
flowchart LR
A["基础语法"] --> B["数组与字符串操作"]
B --> C["排序与查找"]
C --> D["递归与分治"]
D --> E["链表/栈/队列"]
E --> F["树与图"]
5.2 经典算法示例:冒泡排序
#include <stdio.h>
void bubble_sort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
int main(void) {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr) / sizeof(arr[0]);
bubble_sort(arr, n);
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
return 0;
}5.3 二分查找
int binary_search(int arr[], int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2; // 防溢出写法
if (arr[mid] == target) return mid;
if (arr[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1; // 没找到
}坑:
mid = (left + right) / 2在left和right很大时可能溢出。写成left + (right - left) / 2更安全。
5.4 递归:以斐波那契为例
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}这个写法很优雅,但效率极低(指数级)。实际中应该用循环或记忆化。
flowchart TD
A["fib(4)"] --> B["fib(3)"]
A --> C["fib(2)"]
B --> D["fib(2)"]
B --> E["fib(1)"]
D --> F["fib(1)"]
D --> G["fib(0)"]
C --> F
C --> G
避坑:递归一定要有终止条件,否则栈会爆掉。
六、常见坑与避坑技巧
C 语言自由度高,但自由意味着你要对自己负责。下面是新手最常踩的坑。
6.1 未初始化变量
int x;
printf("%d\n", x); // x 的值不确定,可能是任意垃圾值
避坑:声明变量时尽量立刻初始化。
6.2 数组越界
int arr[5] = {1, 2, 3, 4, 5};
printf("%d\n", arr[5]); // 越界!编译器不报错,但访问了非法内存
避坑:写循环时仔细检查边界,特别是 <= 和 <。
6.3 字符串没有 \0
char s[5] = {'h', 'e', 'l', 'l', 'o'};
printf("%s\n", s); // 会一直读到遇到 \0 为止,输出乱码或崩溃
避坑:字符数组当字符串用时,记得留出 \0 的位置。优先用 "hello" 这种字符串字面量初始化。
6.4 scanf 的换行符问题
int a;
char c;
scanf("%d", &a);
scanf("%c", &c); // 这里可能读到残留的换行符
避坑:在 %c 前加空格,让它跳过空白字符:
scanf(" %c", &c);6.5 混用 = 和 ==
if (x = 5) { // 这是赋值,不是判断!
printf("x is 5\n");
}避坑:有些程序员习惯把常量放左边:if (5 == x),这样写错成 5 = x 会直接编译报错。
6.6 整数溢出
int a = 2147483647; // int 最大值
a = a + 1; // 溢出,变成 -2147483648
避坑:处理大数时用 long long,计算前判断范围。
6.7 浮点数精度问题
double a = 0.1 + 0.2;
if (a == 0.3) { // 很可能不相等!
printf("equal\n");
}避坑:浮点数比较用误差范围:
if (fabs(a - 0.3) < 1e-9) {
printf("approx equal\n");
}6.8 悬空指针与野指针
int *p;
*p = 10; // p 指向哪里?可能是任意地址,直接崩溃
int *p = malloc(sizeof(int));
free(p);
*p = 10; // 已释放内存,未定义行为
避坑:
- 指针声明时初始化。
free后立即把指针置NULL。- 使用指针前检查是否为
NULL。
6.9 忘记 break 的 switch
switch (x) {
case 1: printf("one\n");
case 2: printf("two\n"); // x==1 时会连 two 也打印
}避坑:每个 case 结尾加 break,除非你真的想利用 fall-through。
6.10 栈内存返回局部变量地址
int *bad(void) {
int x = 10;
return &x; // x 在函数结束后就被销毁了
}避坑:不要返回局部变量的地址。需要返回动态数据时,用 malloc 申请,或者让调用者提供缓冲区。
七、调试技巧
C 程序出错时,最有效的不是「猜」,而是借助工具。
7.1 善用编译器警告
gcc -Wall -Wextra -g program.c -o program-Wall:开启常见警告。-Wextra:更多警告。-g:生成调试信息,供 gdb 使用。
7.2 用 gdb 调试
gdb ./program
break main
run
next
print x7.3 用 valgrind 检查内存问题
Linux/macOS 下强烈推荐:
valgrind --leak-check=full ./program它能帮你发现内存泄漏、非法访问、未初始化读取等问题。
八、学习路线建议
如果你是零基础,建议按这个顺序来:
flowchart TD
A["第 1 阶段:语法基础"] --> B["第 2 阶段:指针与内存"]
B --> C["第 3 阶段:基础数据结构"]
C --> D["第 4 阶段:算法练习"]
D --> E["第 5 阶段:系统编程/项目"]
A1["变量、类型、控制流、函数"] --> A
A2["数组、字符串、结构体"] --> A
B1["指针、指针运算、动态内存"] --> B
B2["指针与数组、函数指针入门"] --> B
C1["链表、栈、队列"] --> C
C2["递归、排序、查找"] --> C
D1["LeetCode 简单/中等题"] --> D
D2["《算法导论》或《数据结构与算法分析》"] --> D
E1["文件 IO、进程线程"] --> E
E2["小型项目:计算器、学生管理系统、简单 Shell"] --> E
推荐学习资源
- 书籍:
- 《C Primer Plus》:非常适合系统入门。
- 《C 程序设计语言》(K&R):经典,但偏简洁,适合有基础后看。
- 《C 和指针》:指针讲得特别透彻。
- 练习:
- LeetCode、洛谷、PTA、牛客网的 C 语言练习题。
- 自己写小项目:计算器、扫雷、学生成绩管理系统。
九、总结
C 语言的核心可以概括为几句话:
- 程序 = 数据 + 算法。数据用变量和类型表示,算法用控制流和函数组织。
- 指针就是地址。理解了这一点,指针的大部分问题都能迎刃而解。
- 内存要自己管。申请了就释放,用前检查,用完置空。
- 编译器只能帮你检查语法,运行时的坑要自己避开。
学 C 的过程可能会觉得有点艰难,但正因为难,你学到的每一点都会变成真正的能力。等你能熟练地操控指针、自己实现链表和排序算法时,你会发现再看其他语言都轻松很多。
最后送你一句话,也是很多 C 程序员喜欢的风格:
C 语言给了你足够的绳子,你可以用它吊起一头大象,也可以用它吊死自己。
祝学习愉快!