C语言从入门到精通:基础语法、数组、指针、内存、结构体、文件与项目实战

微软技术分享
优质创作者: 编程框架技术领域
领域专家: 操作系统技术领域
2026-09-06 09:30:36

定位:这不是一篇只列语法的“知识点清单”,而是一份可以从头学习、也可以以后回来查阅的 C 语言长篇教程。文章重点放在 代码、内存模型、指针、常见错误和工程实践 上。

如果你已经会 Java / Python,也可以直接跳过非常基础的部分,把重心放到 数组、字符串、指针、内存管理、多文件工程和文件 I/O

学习时最值得优先攻克的 5 个难点
数组为什么从 0 开始;
C 字符串为什么必须有 \0
指针为什么必须区分 int *char *
malloc 返回的到底是什么;
函数传参为什么修改不了外面的普通变量,却能通过指针修改。

建议:每看懂一段代码,都尝试回答“数据在哪里、地址是什么、生命周期到什么时候”。这是学 C 最有效的思路之一。
返回目录

二、C语言到底是什么

C 语言是一门编译型、过程式、接近底层但仍具备高级语言表达能力的编程语言。它允许你控制内存布局、地址和资源生命周期,因此在系统软件、嵌入式、数据库、编译器、网络库等领域长期存在。
C 与 Java / Python 最大的思维差异
对比项 C Java Python
内存管理 经常需要手动考虑 GC 为主 GC + 引用计数为主
指针 直接支持 不暴露裸指针 不暴露裸指针
编译方式 编译为机器相关程序 编译为字节码运行于 JVM 解释/字节码执行
字符串 字符数组 + \0 String 对象 str 对象
越界保护 很弱,需要程序员负责 有运行时检查 有运行时检查
C 最值得学习的不是“语法”,而是这些能力
明白值、地址和对象生命周期;
理解数组和内存连续性的关系;
理解函数调用和栈;
理解动态内存为什么会泄漏;
理解很多高级语言封装背后做了什么。
返回目录

三、开发环境与编译运行

学习 C 至少需要两个东西:编辑器和编译器。
Windows 推荐组合
Visual Studio 2022:适合初学者、调试体验好;
VS Code + GCC/MinGW:更轻量,也更方便理解编译命令;
CLion:工程体验好,但不是必须。
Linux / macOS 常见方式
终端直接使用 GCC 或 Clang 即可。
检查 GCC

gcc --version

编译并运行

gcc hello.c -o hello
./hello

Windows PowerShell 中通常运行:

.\hello.exe

建议从一开始就打开警告

gcc -std=c17 -Wall -Wextra -pedantic hello.c -o hello

-Wall -Wextra 能帮助你尽早发现未使用变量、可疑转换、格式符不匹配等问题。不要把编译警告当成“可以先不管”。

返回目录

四、C程序从源码到可执行文件
很多人会写 gcc main.c -o main,但不知道 GCC 背后做了什么。理解这一节后,你会更容易读懂“头文件错误”“重复定义”“undefined reference”等问题。

在这里插入图片描述

预处理
处理 #include#define、条件编译等。

gcc -E hello.c -o hello.i

编译
把预处理后的 C 代码翻译成汇编代码。

gcc -S hello.i -o hello.s

汇编
把汇编代码生成目标文件。

gcc -c hello.s -o hello.o

链接
把目标文件和需要的库组合成可执行程序。

gcc hello.o -o hello

一个特别常见的错误:声明了函数但没有参与链接

int add(int a, int b);

int main(void) {
    return add(1, 2);
}

如果 add 的实现位于 math.c,只编译 main.c 就可能链接失败。应该:

gcc main.c math.c -o app

返回目录

五、基础语法与第一个程序

#include <stdio.h>

int main(void) {
    printf("Hello, C!\n");
    return 0;
}

#include <stdio.h>
告诉编译器后续代码会使用标准输入输出库中声明的接口,例如 printf
int main(void)
main 是程序入口。void 明确表示该版本不接收参数。
return 0
通常表示程序正常结束。
注释

// 单行注释

/*
   多行注释
*/

语句以分号结束

int age = 21;
printf("%d\n", age);

代码块使用 {}

if (age >= 18) {
    printf("adult\n");
}

写 C 代码时建议从一开始保持统一格式

if (condition) {
    statement1();
    statement2();
}

良好的缩进不影响机器执行,但会极大影响你自己发现逻辑错误的速度。

返回目录

六、变量、常量、数据类型与 sizeof
变量可以理解为:一块有类型、有地址、当前保存某个值的存储区域。

在这里插入图片描述

常见基础类型

char c = 'A';
short s = 100;
int age = 21;
long count = 100000L;
float price = 9.9f;
double pi = 3.1415926;

不要死记每个平台的字节数
C 标准对很多类型只规定最小范围和相对关系,因此应使用 sizeof 查看当前平台实际大小。

#include <stdio.h>

int main(void) {
    printf("char   : %zu\n", sizeof(char));
    printf("short  : %zu\n", sizeof(short));
    printf("int    : %zu\n", sizeof(int));
    printf("long   : %zu\n", sizeof(long));
    printf("float  : %zu\n", sizeof(float));
    printf("double : %zu\n", sizeof(double));
    return 0;
}

signedunsigned

unsigned int count = 100U;
signed int temperature = -10;

unsigned 适合明确不需要负数的场景,但不要把它当作“更大范围的普通 int”随便混用。有符号和无符号混合比较很容易制造 bug。
常量

const double PI = 3.1415926;

const 的核心价值不是“神秘的常量语法”,而是告诉编译器和读代码的人:这里不应该被修改。
类型转换

int a = 5;
int b = 2;
double result = (double)a / b;

如果不做转换:

double result = a / b;

a / b 会先进行整数除法得到 2,再转成 2.0
易错点:整数溢出

int x = 100000;
int y = 100000;
long long result = (long long)x * y;

如果先在 int 中完成乘法再转成 long long,可能已经溢出了。

返回目录

七、输入输出:printf、scanf 与常见坑
printf 常见格式符
格式符 常见用途
%d int
%u unsigned int
%ld long
%lld long long
%f printf 中输出浮点数
%c 字符
%s C 字符串
%p 地址,通常配 (void *)
%zu size_t

#include <stdio.h>

int main(void) {
    int age = 21;
    double score = 95.5;
    printf("age=%d, score=%.1f\n", age, score);
    return 0;
}

scanf 为什么经常要写 &

int age;
scanf("%d", &age);

scanf 要修改调用者的变量,因此需要拿到这个变量的地址。
字符数组是例外之一

char name[32];
scanf("%31s", name);

数组名在这里会转换为首元素地址,所以不再写 &name
一定要检查返回值

int age;
if (scanf("%d", &age) != 1) {
    printf("输入无效\n");
    return 1;
}

为什么 %31s%s 更好

char name[32];
scanf("%31s", name);

最多读取 31 个非空白字符,留下 1 个位置给结尾 \0,可以降低缓冲区溢出的风险。
读取一整行更推荐 fgets

#include <stdio.h>

int main(void) {
    char line[128];
    if (fgets(line, sizeof line, stdin) != NULL) {
        printf("%s", line);
    }
    return 0;
}

返回目录

八、运算符与表达式
算术运算

int a = 10;
int b = 3;
printf("%d\n", a + b);
printf("%d\n", a - b);
printf("%d\n", a * b);
printf("%d\n", a / b);
printf("%d\n", a % b);

关系运算

if (a >= b) {
    printf("a >= b\n");
}

逻辑运算

if (age >= 18 && score >= 60) {
    printf("条件成立\n");
}

短路求值非常重要

if (p != NULL && *p > 0) {
    printf("valid\n");
}

如果 p == NULL,右侧 *p > 0 不会执行,因此避免了空指针解引用。
++ii++
单独作为语句时:

++i;
i++;

通常最终效果一样。区别主要体现在表达式求值时。

int i = 5;
int a = i++;

此时 a 得到旧值 5,之后 i 变 6。
不要写过度复杂、依赖求值细节的表达式
可读性比“炫技”重要。复杂逻辑拆成多行,往往更安全。
位运算

unsigned int flags = 0;
flags |= 1u << 2;
flags &= ~(1u << 2);

位运算常用于标志位、协议、硬件寄存器和压缩表示。

返回目录

九、选择结构:if 与 switch

在这里插入图片描述

if

if (score >= 60) {
    printf("pass\n");
}

if ... else

if (score >= 60) {
    printf("pass\n");
} else {
    printf("fail\n");
}

多分支

if (score >= 90) {
    printf("A\n");
} else if (score >= 80) {
    printf("B\n");
} else if (score >= 60) {
    printf("C\n");
} else {
    printf("D\n");
}

switch

switch (choice) {
    case 1:
        printf("add\n");
        break;
    case 2:
        printf("delete\n");
        break;
    default:
        printf("unknown\n");
        break;
}

忘记 break 会发生什么

switch (x) {
    case 1:
        printf("one\n");
    case 2:
        printf("two\n");
        break;
}

x == 1 时,执行完 case 1 后还会继续落入 case 2。这种行为叫 fall-through。有时它是故意设计的,但初学阶段大多数情况是 bug。

返回目录

十、循环结构:for、while、do-while
for
适合循环次数相对明确的场景。

for (int i = 0; i < 5; ++i) {
    printf("%d\n", i);
}

while
适合“条件成立就继续”的场景。

int n = 5;
while (n > 0) {
    printf("%d\n", n);
    --n;
}

do-while
至少执行一次。

int choice;
do {
    printf("1. add\n0. exit\n");
    scanf("%d", &choice);
} while (choice != 0);

三种循环怎么选
需求 推荐
明确遍历 0 到 n-1 for
不确定执行次数,只看条件 while
菜单至少显示一次 do-while
break
立刻结束当前循环。

for (int i = 0; i < 100; ++i) {
    if (i == 10) {
        break;
    }
}

continue
跳过本轮剩余语句,进入下一轮。

for (int i = 0; i < 10; ++i) {
    if (i % 2 == 0) {
        continue;
    }
    printf("%d\n", i);
}

经典练习:九九乘法表

#include <stdio.h>

int main(void) {
    for (int i = 1; i <= 9; ++i) {
        for (int j = 1; j <= i; ++j) {
            printf("%d*%d=%-2d ", j, i, i * j);
        }
        printf("\n");
    }
    return 0;
}

返回目录

十一、函数:参数、返回值、声明与作用域
函数让代码从“所有逻辑挤在 main”变成可拆分、可复用、可测试的模块。

在这里插入图片描述

最基本的函数

int add(int a, int b) {
    return a + b;
}

函数声明

int add(int a, int b);

声明告诉编译器函数的名字、参数和返回类型,实现可以放在后面甚至另一个 .c 文件。
C 参数传递的核心:值传递

#include <stdio.h>

void change(int x) {
    x = 100;
}

int main(void) {
    int a = 10;
    change(a);
    printf("%d\n", a);
    return 0;
}

输出仍然是 10。因为 change 拿到的是 a 的值的副本。
想修改外部变量怎么办

#include <stdio.h>

void change(int *p) {
    *p = 100;
}

int main(void) {
    int a = 10;
    change(&a);
    printf("%d\n", a);
    return 0;
}

现在函数虽然仍然是“值传递”,但传进去的值变成了地址。通过这个地址,就能修改原对象。
局部变量与全局变量

int global_count = 0;

void test(void) {
    int local_count = 10;
}

局部变量只在对应作用域内可见。全局变量可见范围更大,但也更容易造成模块耦合,实际工程不要滥用。
static 局部变量

#include <stdio.h>

void counter(void) {
    static int count = 0;
    ++count;
    printf("%d\n", count);
}

int main(void) {
    counter();
    counter();
    counter();
    return 0;
}

它的作用域仍在函数内部,但生命周期贯穿整个程序运行期。

返回目录

十二、递归:函数调用自己到底发生了什么
递归就是函数直接或间接调用自身。
阶乘

long long factorial(int n) {
    if (n <= 1) {
        return 1;
    }
    return (long long)n * factorial(n - 1);
}

递归必须考虑两个东西:
终止条件;
每次调用是否更接近终止条件。
递归调用栈
计算 factorial(4)

factorial(4)
  factorial(3)
    factorial(2)
      factorial(1)

返回时逐层展开:

1 2624

递归不是越多越高级
深度过大会占用大量栈空间,甚至导致栈溢出。能用清晰循环解决的问题,不必强行递归。
斐波那契的低效递归

long long fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

这个写法会重复计算大量相同子问题,更适合教学,不适合大规模实际计算。

返回目录

十三、一维数组与二维数组
数组是一段连续存放相同类型元素的内存区域。
一维数组

int scores[5] = {90, 88, 76, 95, 82};

访问:

printf("%d\n", scores[0]);

为什么下标从 0 开始
数组访问本质上与“首地址 + 偏移”相关。

scores[i]

可以理解为第 i 个元素距离首元素偏移 i * sizeof(int) 字节。
遍历数组

#include <stdio.h>

int main(void) {
    int arr[] = {10, 20, 30, 40, 50};
    size_t n = sizeof arr / sizeof arr[0];

    for (size_t i = 0; i < n; ++i) {
        printf("%d ", arr[i]);
    }
    printf("\n");
    return 0;
}

sizeof arr / sizeof arr[0] 什么时候能用
只能在 arr 仍然是真正数组对象的作用域里可靠使用。

void print_array(int arr[]) {
    printf("%zu\n", sizeof arr);
}

函数参数里的 int arr[] 实际会调整为 int *arr,所以这里得到的是指针大小,不是整个数组大小。
二维数组

int matrix[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

遍历:

for (int i = 0; i < 2; ++i) {
    for (int j = 0; j < 3; ++j) {
        printf("%d ", matrix[i][j]);
    }
    printf("\n");
}

二维数组在内存中仍然是连续布局,只是我们把它按“行”来解释。

返回目录

十四、字符串:字符数组、\0 与常用函数
C 语言没有像 Java String 那样的内建字符串对象。最常见的字符串表示方式是:以 \0 结尾的字符序列。

在这里插入图片描述

定义字符串

char name[] = "hello";

实际需要 6 个字符位置:

h e l l o \0

strlensizeof 完全不是一回事

#include <stdio.h>
#include <string.h>

int main(void) {
    char s[] = "hello";
    printf("strlen = %zu\n", strlen(s));
    printf("sizeof = %zu\n", sizeof s);
    return 0;
}

典型结果:

strlen = 5
sizeof = 6

strlen 数的是 \0 前的字符数;sizeof 看的是整个数组对象占多少字节。
常见字符串函数

strlen(s);
strcmp(a, b);
strcpy(dest, src);
strcat(dest, src);

strcpy 的危险点

char dest[4];
strcpy(dest, "hello");

目标空间装不下,会造成越界写入。C 不会自动扩容。
更推荐明确控制容量
如果允许,优先使用能显式限制输入长度的接口,并始终保证目标缓冲区有空间容纳结尾 \0
字符串字面量不要当作可写数组

char *p = "hello";
p[0] = 'H';

修改字符串字面量属于未定义行为。需要修改时:

char p[] = "hello";
p[0] = 'H';

返回目录

十五、指针入门:地址、& 与 *
指针是 C 语言最重要的主题之一。它并不是“神秘高级语法”,本质只有一句话:指针变量用于保存地址。

在这里插入图片描述

最基本例子

#include <stdio.h>

int main(void) {
    int a = 10;
    int *p = &a;

    printf("a   = %d\n", a);
    printf("&a  = %p\n", (void *)&a);
    printf("p   = %p\n", (void *)p);
    printf("*p  = %d\n", *p);
    return 0;
}

四个表达式一定要分清
写法 含义
a 变量当前的值
&a 变量 a 的地址
p 指针变量中保存的地址
*p 访问 p 所指向的对象
通过指针修改对象

int a = 10;
int *p = &a;
*p = 99;

此时 a 也变成 99,因为 *p 指的就是 a 这个对象。
指针类型为什么重要

int *pi;
double *pd;
char *pc;

不同类型决定了解引用时如何解释内存,也影响指针加减时一次跨过多少字节。
空指针

int *p = NULL;

NULL 表示当前没有指向有效对象。绝对不要解引用 NULL。

if (p != NULL) {
    printf("%d\n", *p);
}

野指针

int *p;
*p = 10;

p 没有初始化就被解引用,这是高危错误。

返回目录

十六、指针与数组:arr[i] 为什么等价于 *(arr+i)
在大多数表达式中,数组名会转换为指向首元素的指针。

int arr[3] = {10, 20, 30};
int *p = arr;

两种访问形式

arr[1]
*(arr + 1)

两者访问的是同一个元素。
指针移动不是简单“地址 + 1 字节”

int *p = arr;
++p;

p 会移动到下一个 int 元素,而不是只加一个字节。
用指针遍历数组

#include <stdio.h>

int main(void) {
    int arr[] = {1, 2, 3, 4, 5};
    int *p = arr;
    size_t n = sizeof arr / sizeof arr[0];

    for (size_t i = 0; i < n; ++i) {
        printf("%d ", *(p + i));
    }
    printf("\n");
    return 0;
}

数组不是指针
这是特别重要的结论。

int arr[10];
int *p = arr;

arr 是数组对象,p 是指针对象。它们在很多表达式中表现相似,但 sizeof、可赋值性等方面完全不同。

sizeof arr
sizeof p

通常不会相等。
数组传参

void print_array(const int arr[], size_t n) {
    for (size_t i = 0; i < n; ++i) {
        printf("%d ", arr[i]);
    }
}

必须额外传入长度,因为函数无法从这个参数重新推导出原始数组总长度。

返回目录

十七、指针进阶:二级指针、指针数组、数组指针、函数指针

二级指针

在这里插入图片描述

int a = 10;
int *p = &a;
int **pp = &p;

printf("%d\n", **pp);

二级指针最常见的使用场景之一,是函数需要修改调用者的指针变量本身。

#include <stdlib.h>

int create_array(int **out, size_t n) {
    *out = malloc(n * sizeof **out);
    return *out != NULL;
}

调用:

int *arr = NULL;
if (create_array(&arr, 10)) {
    free(arr);
    arr = NULL;
}

指针数组

int *p[3];

p 先和 [] 结合,所以它是数组,数组的每个元素都是 int *
3. 数组指针

int (*p)[3];

括号让 *p 先结合,因此 p 是指针,它指向“包含 3 个 int 的数组”。

在这里插入图片描述

函数指针

int add(int a, int b) {
    return a + b;
}

int main(void) {
    int (*operation)(int, int) = add;
    int result = operation(3, 4);
    return result == 7 ? 0 : 1;
}

函数指针常见于:
回调函数;
策略切换;
排序比较函数;
驱动/协议接口表。
5. const 与指针

const int *p;

不能通过 p 修改所指向的 int

int *const p = &a;

p 自身不能再指向别处。

const int *const p = &a;

两者都限制。

返回目录

十八、内存模型:栈、堆、全局区与生命周期
学指针如果不理解生命周期,很容易出现“明明地址还在,为什么不能用”的问题。

在这里插入图片描述

栈上的典型对象

void test(void) {
    int x = 10;
}

x 的生命周期到函数执行结束为止。
不能返回局部变量地址
错误示例:

int *bad(void) {
    int x = 10;
    return &x;
}

函数结束后 x 生命周期已经结束,这个地址不能继续作为有效对象使用。
静态存储期

static int count = 0;

静态对象的生命周期贯穿整个程序运行期。
堆内存

int *p = malloc(sizeof *p);

堆对象不会因为当前函数返回就自动释放。它会一直存在,直到你 free,或者进程结束由操作系统整体回收。
一个重要思维模型
看到指针时不要只问:

“这个地址是多少?”
更应该问:
“这个地址对应的对象现在还活着吗?”
这就是 C 中大量悬空指针问题的根源。
返回目录

十九、动态内存:malloc、calloc、realloc、free
动态内存用于运行时才能确定需要多少空间的场景。

在这里插入图片描述

malloc

#include <stdlib.h>

int *arr = malloc(10 * sizeof *arr);
if (arr == NULL) {
    return 1;
}

推荐 sizeof *arr,这样以后即使修改 arr 的类型,也更不容易漏改。
在 C 中通常不需要强制转换 malloc 返回值

int *arr = malloc(n * sizeof *arr);

void * 可以自动转换为对象指针类型。额外强制转换反而可能掩盖忘记包含 <stdlib.h> 的问题。
calloc

int *arr = calloc(n, sizeof *arr);

它会为 n 个元素分配空间,并把分配到的字节初始化为全 0。
realloc
不要直接覆盖原指针:

int *temp = realloc(arr, new_size * sizeof *arr);
if (temp != NULL) {
    arr = temp;
}

如果写成:

arr = realloc(arr, new_size * sizeof *arr);

一旦失败返回 NULL,你可能丢失旧地址,造成内存泄漏。
free

free(arr);
arr = NULL;

free 之后原来的堆对象已经不存在,再访问就是 use-after-free。
错误:重复释放

free(p);
free(p);

属于未定义行为。
错误:释放栈变量

int x = 10;
free(&x);

free 只能用于兼容的动态分配结果,不能随便释放任意地址。
一个完整动态数组示例

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t n;
    printf("元素数量:");
    if (scanf("%zu", &n) != 1 || n == 0) {
        return 1;
    }

    int *arr = malloc(n * sizeof *arr);
    if (arr == NULL) {
        return 1;
    }

    for (size_t i = 0; i < n; ++i) {
        arr[i] = (int)(i + 1);
    }

    for (size_t i = 0; i < n; ++i) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    free(arr);
    arr = NULL;
    return 0;
}

返回目录

二十、结构体、typedef、enum、union 与内存对齐
结构体:把多个字段组合成一个对象

typedef struct {
    int id;
    char name[32];
    double score;
} Student;

使用

Student s = {1001, "Alice", 95.5};
printf("%d %s %.1f\n", s.id, s.name, s.score);

结构体指针与 ->

Student *p = &s;
printf("%s\n", p->name);

p->name 等价于:

(*p).name

typedef

typedef unsigned long ulong;

它创建的是类型别名,不是新建一个完全独立的底层类型。
enum

typedef enum {
    STATUS_OK,
    STATUS_ERROR,
    STATUS_NOT_FOUND
} Status;

枚举比散落在代码里的“魔法数字”更有可读性。
union

union Value {
    int i;
    double d;
    char c;
};

联合体成员共享同一段存储空间,同一时刻通常只把其中一种解释当作有效值。
结构体内存对齐

在这里插入图片描述

struct Demo {
    char c;
    int i;
    short s;
};

结构体大小不一定等于 1 + 4 + 2。为了满足成员对齐要求,编译器可能插入 padding。

不要只凭示意图死记“永远是多少字节”,实际结果由平台 ABI、编译器和成员类型共同决定。使用 sizeof 验证当前环境。
返回目录

二十一、预处理、宏、static、extern 与多文件工程
宏定义

#define MAX_STUDENTS 100

宏是预处理阶段的文本替换,不是变量。
带参数宏最常见的坑:缺括号
错误:

#define SQUARE(x) x * x

调用:

SQUARE(1 + 2)

可能展开成:

1 + 2 * 1 + 2

更安全:

#define SQUARE(x) ((x) * (x))

但宏仍可能对带副作用的参数产生问题,所以不要把所有小函数都改成宏。
头文件保护

#ifndef STUDENT_H
#define STUDENT_H

/* declarations */

#endif

static 在文件作用域

static int helper(void) {
    return 42;
}

这样可以限制符号只在当前源文件内部使用,降低模块之间的名字冲突。
extern

extern int global_count;

表示该对象的定义位于其他位置。实际工程中比起到处共享全局变量,更推荐通过函数接口管理模块状态。
多文件工程结构

在这里插入图片描述

project/
├── main.c
├── student.h
├── student.c
└── storage.c

编译:

gcc -std=c17 -Wall -Wextra -pedantic main.c student.c storage.c -o app

.h.c 的职责建议

文件 主要职责
.h 类型、宏、函数声明、对外接口
.c 函数实现、模块内部细节
main.c 程序入口与高层流程
返回目录

二十二、文件操作:文本文件与二进制文件
程序退出后,内存中的普通数据会消失。文件 I/O 让数据可以持久化。

在这里插入图片描述

打开文件

FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
    return 1;
}

常见模式
模式 含义
r 读,文件必须存在
w 写,通常截断原内容
a 追加
rb 二进制读
wb 二进制写
文本写入

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("score.txt", "w");
    if (fp == NULL) {
        return 1;
    }

    fprintf(fp, "%d %s %.2f\n", 1001, "Alice", 95.5);
    fclose(fp);
    return 0;
}

文本读取

char line[128];
while (fgets(line, sizeof line, fp) != NULL) {
    printf("%s", line);
}

二进制写入结构体数组

size_t written = fwrite(students, sizeof(Student), count, fp);

二进制读取

size_t count = fread(students, sizeof(Student), capacity, fp);

注意:直接保存结构体二进制不是通用跨平台文件格式

原因包括:
字节序;
对齐与 padding;
类型大小;
结构体版本变化。
对于课程项目和同平台简单程序,这样做很方便;真正跨平台长期存储,应设计明确的序列化格式。
返回目录

二十三、调试与 C 语言高频错误
C 最可怕的不是“报错”,而是有些错误不一定立刻报错。

在这里插入图片描述

数组越界

int arr[3] = {1, 2, 3};
arr[3] = 99;

有效下标只有 0、1、2。
2. NULL 解引用

int *p = NULL;
printf("%d\n", *p);

野指针

int *p;
*p = 10;

悬空指针

int *p = malloc(sizeof *p);
free(p);
printf("%d\n", *p);

内存泄漏

void test(void) {
    int *p = malloc(100 * sizeof *p);
    if (p == NULL) {
        return;
    }
}

函数离开后地址丢失,再也无法 free
6. ===

if (x = 0) {
}

这是赋值,不是比较。编译器警告很重要。
7. 格式符不匹配

double x = 3.14;
printf("%d\n", x);

格式符与实参类型不匹配会导致未定义行为。
8. 返回局部变量地址

int *bad(void) {
    int x = 10;
    return &x;
}

忘记检查库函数结果
mallocfopenscanffread 等都可能失败或只完成部分操作。

  1. 编译器警告和 Sanitizer
    GCC / Clang 调试阶段推荐:
gcc -std=c17 -Wall -Wextra -Wpedantic -fsanitize=address,undefined -g main.c -o app

AddressSanitizer / UndefinedBehaviorSanitizer 对越界、use-after-free、部分未定义行为非常有帮助。

返回目录

二十四、数据结构入门:手写单链表
数组的容量和内存布局比较固定,而链表通过节点和指针把数据连接起来。
节点定义

typedef struct Node {
    int value;
    struct Node *next;
} Node;

创建节点

#include <stdlib.h>

Node *create_node(int value) {
    Node *node = malloc(sizeof *node);
    if (node == NULL) {
        return NULL;
    }
    node->value = value;
    node->next = NULL;
    return node;
}

头插法

int push_front(Node **head, int value) {
    Node *node = create_node(value);
    if (node == NULL) {
        return 0;
    }

    node->next = *head;
    *head = node;
    return 1;
}

为什么是 Node **head?因为函数需要修改调用者保存的“头指针”本身。
遍历

void print_list(const Node *head) {
    for (const Node *p = head; p != NULL; p = p->next) {
        printf("%d ", p->value);
    }
    printf("\n");
}

释放整个链表

void destroy_list(Node **head) {
    Node *current = *head;
    while (current != NULL) {
        Node *next = current->next;
        free(current);
        current = next;
    }
    *head = NULL;
}

这一小节把前面大量知识真正串起来:

结构体;
结构体指针;
动态内存;
二级指针;
生命周期;
循环。
返回目录

二十五、综合项目:学生信息管理系统
最后用一个完整项目把核心知识串起来。
项目功能
添加学生;
删除学生;
按学号查询;
显示全部学生;
按成绩排序;
计算平均分;
启动时加载文件;
退出时保存文件。

在这里插入图片描述

工程结构

student_system/
├── main.c
├── student.h
├── student.c
├── storage.c
└── students.dat

student.h

#ifndef STUDENT_H
#define STUDENT_H

#include <stddef.h>

#define NAME_LEN 32

typedef struct {
    int id;
    char name[NAME_LEN];
    double score;
} Student;

int add_student(Student students[], size_t capacity, size_t *count);
int delete_student(Student students[], size_t *count, int id);
Student *find_student(Student students[], size_t count, int id);
void print_students(const Student students[], size_t count);
void sort_by_score(Student students[], size_t count);
double average_score(const Student students[], size_t count);

int save_students(const char *filename, const Student students[], size_t count);
int load_students(const char *filename, Student students[], size_t capacity, size_t *count);

#endif

student.c 核心查询函数

Student *find_student(Student students[], size_t count, int id) {
    for (size_t i = 0; i < count; ++i) {
        if (students[i].id == id) {
            return &students[i];
        }
    }
    return NULL;
}

删除学生

int delete_student(Student students[], size_t *count, int id) {
    for (size_t i = 0; i < *count; ++i) {
        if (students[i].id == id) {
            for (size_t j = i + 1; j < *count; ++j) {
                students[j - 1] = students[j];
            }
            --(*count);
            return 1;
        }
    }
    return 0;
}

排序

void sort_by_score(Student students[], size_t count) {
    for (size_t i = 0; i + 1 < count; ++i) {
        for (size_t j = 0; j + 1 < count - i; ++j) {
            if (students[j].score < students[j + 1].score) {
                Student temp = students[j];
                students[j] = students[j + 1];
                students[j + 1] = temp;
            }
        }
    }
}

文件保存

int save_students(const char *filename, const Student students[], size_t count) {
    FILE *fp = fopen(filename, "wb");
    if (fp == NULL) {
        return 0;
    }

    size_t written = fwrite(students, sizeof(Student), count, fp);
    fclose(fp);
    return written == count;
}

编译整个工程

gcc -std=c17 -Wall -Wextra -pedantic main.c student.c storage.c -o student_system

完整源码已经放在配套源码包的 src/student_system/ 中。

这个项目真正训练了什么
知识点 在项目中的应用
结构体 Student 数据模型
数组 保存多名学生
指针 修改 count、返回查询结果
函数 拆分增删改查
文件 保存与恢复数据
头文件 暴露模块接口
多文件 分离 UI、业务与存储
返回目录

二十六、C语言高频易错点与面试题

数组和指针一样吗?
不一样。数组是数组对象,指针是保存地址的对象。数组名只是在很多表达式中会转换为首元素指针。

  1. sizeofstrlen 有什么区别?
    sizeof:操作类型或对象大小;
    strlen:扫描 C 字符串直到 \0,返回字符数。
  2. malloc 返回的是什么?
    返回指向所分配内存块起始位置的 void *,失败返回 NULL
  3. 为什么 free 后常把指针设为 NULL
    free 只释放对象,不会自动修改你的指针变量。原指针会成为悬空指针。置空不能解决所有别名问题,但能减少再次误用该变量的风险。
  4. 为什么函数修改不了普通参数?
    因为参数是值传递。函数修改的是自己的形参副本。
  5. 为什么传指针后能修改?
    仍然是值传递,只是传入的“值”是地址。函数通过这个地址访问原对象。
  6. char *s = "abc"char s[] = "abc" 有什么不同?
    前者 s 是指针,通常指向字符串字面量,不应该修改内容;后者创建一个可修改字符数组。
  7. 什么是未定义行为?
    标准不规定程序必须产生什么结果。例如数组越界、使用已释放对象、修改字符串字面量等。它不是“保证报错”,可能表现为崩溃、错误结果,甚至看起来暂时正常。
  8. 什么是内存泄漏?
    动态分配的内存失去所有可用于释放它的有效引用,程序无法再 free 这块内存。
  9. static 有哪些常见含义?
    函数内部:静态存储期,值跨调用保留;
    文件作用域:限制名称的链接属性,使它只在当前翻译单元内部使用。
    返回目录

二十七、练习路线:从语法题到项目题
第一阶段:基础控制
判断奇偶数;
判断闰年;
计算 1 到 n 的和;
输出九九乘法表;
求三个数最大值;
猜数字小游戏。
第二阶段:数组与字符串
求数组最大值和最小值;
数组逆序;
冒泡排序;
统计字符串中字母、数字、空格数量;
手写字符串长度函数;
判断回文字符串。
第三阶段:函数与指针
用函数交换两个变量;
用指针求数组和;
手写 my_strlen
手写 my_strcpy
使用二级指针动态创建数组。
第四阶段:结构体与文件
学生成绩排序;
通讯录;
图书管理;
保存结构体到文件;
启动程序时恢复数据。
第五阶段:工程化
把一个单文件项目拆成:

main.c
module.h
module.c
storage.c

然后启用完整警告并尝试 AddressSanitizer。

返回目录

二十八、C语言核心速查表
基本程序

#include <stdio.h>

int main(void) {
    return 0;
}

输入输出

int x;
scanf("%d", &x);
printf("%d\n", x);

数组长度

size_t n = sizeof arr / sizeof arr[0];

指针

int a = 10;
int *p = &a;
printf("%d\n", *p);

交换两个变量

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

动态内存

int *arr = malloc(n * sizeof *arr);
if (arr == NULL) {
    return 1;
}

free(arr);
arr = NULL;

结构体

typedef struct {
    int id;
    char name[32];
} Student;

文件

FILE *fp = fopen("data.txt", "r");
if (fp != NULL) {
    fclose(fp);
}

推荐编译参数

gcc -std=c17 -Wall -Wextra -Wpedantic -g main.c -o app

调试阶段加入 Sanitizer

gcc -std=c17 -Wall -Wextra -fsanitize=address,undefined -g main.c -o app

返回目录

二十九、从入门走向真正“精通”的路线
所谓“精通 C”,绝不是把每一个库函数都背下来。更重要的是形成稳定的底层判断能力。
第一层:会写
熟悉语法;
会写循环和函数;
会用数组、字符串;
能完成简单项目。
第二层:会解释
看到代码能解释:
变量存在哪里;
指针保存什么;
生命周期多久;
哪些访问可能越界;
谁负责释放资源。
第三层:会设计
模块接口清晰;
少依赖全局状态;
失败路径有处理;
动态内存所有权明确;
能把项目拆成多个源文件。
第四层:会排错
真正拉开差距的是调试能力:
看编译警告;
用调试器单步;
查调用栈;
用 Sanitizer;
从“随机崩溃”反推边界和生命周期问题。
推荐后续路线

C 语法
  ↓
数据结构与算法
  ↓
Linux C / 系统编程
  ↓
进程、线程、文件、网络
  ↓
操作系统 / 编译原理 / 嵌入式 / 高性能方向

最后一句
C 语言真正难的不是 iffor,甚至也不只是“指针”。真正难的是你要开始对内存、边界、生命周期和资源所有权负责。
一旦这套思维建立起来,你学到的不只是一门 C 语言,而是更接近计算机真实运行方式的一套编程基本功。
返回目录


文章来源: https://blog.csdn.net/2401_86617027/article/details/163975566
版权声明: 本文为博主原创文章,遵循CC 4.0 BY-SA 知识共享协议,转载请附上原文出处链接和本声明。


...全文
56 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

7,463

社区成员

发帖
与我相关
我的任务
社区描述
微软技术社区为中国的开发者们提供一个技术干货传播平台,传递微软全球的技术和产品最新动态,分享各大技术方向的学习资源,同时也涵盖针对不同行业和场景的实践案例,希望可以全方位地帮助你获取更多知识和技能。
windowsmicrosoft 企业社区
社区管理员
  • 微软技术分享
  • 郑子铭
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

微软技术社区为中国的开发者们提供一个技术干货传播平台,传递微软全球的技术和产品最新动态,分享各大技术方向的学习资源,同时也涵盖针对不同行业和场景的实践案例,希望可以全方位地帮助你获取更多知识和技能。

予力众生,成就不凡!微软致力于用技术改变世界,助力企业实现数字化转型。

试试用AI创作助手写篇文章吧