🧭 C/C++ 学习站

C 语言入门 + 进阶训练

公网版 · 手机/电脑自适应

🐣 纯新手先看这一框(30 秒秒懂)

1

代码是什么?就是写给电脑的“步骤清单”。比如“先打印一句话,再把两个数相加”,电脑会一步步照做。

2

为什么先学 C?C 离硬件近,能让你真正搞懂“变量存在哪、指针指向谁”。C++/Java/Go/Rust 都从 C 演化来,学好 C 后面更快。

3

怎么跑起来?你写的代码电脑看不懂,要经“编译器”翻译成机器指令。本站「练习场」点一下就帮你编译运行,不用自己装环境。

课程说明从零基础到能写小型工程,手机电脑都能学

C 语言诞生于 1972 年( Dennis Ritchie 在贝尔实验室),是 Unix 操作系统的实现语言,也是 C++、Java、Go、Rust 等大量现代语言的“祖先”。它贴近硬件、运行高效、可控性强,至今仍是操作系统、嵌入式、游戏引擎、高频交易等性能敏感领域的首选。

本课程的定位:既讲“怎么写”,更讲“为什么”和“会踩什么坑”。前半部分「入门篇」把语法和运行机制讲透;后半部分「进阶训练篇」聚焦指针、内存、数据结构与工程化,每一节都配可复制代码、练习与答案。

学习方法 ① 每段代码都要自己敲、用编译器跑;② 编译器警告 -Wall -Wextra 当错误看;③ 遇到段错误(segmentation fault)不要慌,按本文“常见陷阱”逐条排查;④ 手机上目录可点左上角 ☰ 展开。
公网权威资料 cppreference.com(C 标准库) · GNU C Manual · C 标准文档(ISO/IEC 9899,C17 为现行主流版本)。

1. 环境搭建与第一个程序编译器、编译四阶段、Hello World

1.1 安装编译器

  • Windows:装 MSYS2(包管理器里 pacman -S mingw-w64-x86_64-gcc),或装 Visual Studio(含 MSVC);新手也可用 CLion 这类 IDE。
  • macOSxcode-select --install 装好命令行工具,即用 clang
  • Linuxsudo apt install gcc(Debian/Ubuntu)。

1.2 编译的四个阶段

.c 源码到可执行文件,gcc 大致经历:预处理(展开 #include、宏)→ 编译(生成汇编)→ 汇编(生成机器码目标文件 .o)→ 链接(合并库,生成可执行)。理解这四步,后面排查“头文件找不到”“未定义引用”就有方向。

gcc -E hello.c -o hello.i    # 只看预处理结果
gcc -S hello.c               # 生成汇编 hello.s
gcc -c hello.c -o hello.o    # 只编译不链接
gcc hello.o -o hello         # 链接成可执行

1.3 第一个程序

#include <stdio.h>          // 标准输入输出头文件

int main(void) {            // 程序唯一入口
    printf("Hello, C!\n");   // \n 是换行
    return 0;                // 返回 0 表示正常结束
}

1.4 编译与运行

gcc -Wall -Wextra -std=c11 hello.c -o hello
./hello          # Linux / macOS
hello.exe        # Windows
关键概念 #include 是“预处理指令”,把头文件内容原样贴进来;main 的返回值会作为进程的“退出码”交还操作系统(0=成功,非 0=异常);void 表示不接收参数,比空括号更严谨(C 里空括号表示“参数未声明”,不够安全)。

2. 数据类型与变量整型家族、浮点、字符、类型大小与取值范围

C 是静态强类型语言:变量必须先声明类型,且类型在编译期确定。类型的核心作用是“告诉编译器这块内存怎么解释、占多大”。

类型含义64 位典型大小大致取值范围
char字符/最小整数1 字节-128 ~ 127(或 0~255)
short短整型2 字节-32768 ~ 32767
int基本整型4 字节约 ±21 亿
long长整型8 字节(Linux)/4(Win)很大
long long更长整型8 字节±9×10¹⁸
float单精度浮点4 字节约 7 位有效数字
double双精度浮点8 字节约 15 位有效数字
_Bool布尔(C99)1 字节0 为假,非 0 为真

2.1 有符号与无符号

整型默认有符号(signed),可正可负;加 unsigned 后只表示非负数,取值范围整体平移到 0 起。例如 unsigned char 范围是 0~255。混用有/无符号比较极易出 bug(见陷阱章)。

2.2 变量声明、初始化与作用域

#include <stdio.h>

int g = 100;                 // 全局变量:文件作用域,默认 0 初始化

int main(void) {
    int age = 18;            // 局部变量:块作用域,必须自己初始化
    double price = 9.9;
    char ch = 'A';           // 字符用单引号,本质是整数 65
    _Bool ok = 1;            // 真
    printf("age=%d price=%.2f ch=%c ok=%d\n", age, price, ch, ok);
    printf("sizeof(int)=%zu\n", sizeof(int));   // %zu 打印 size_t
    return 0;
}
高危 局部变量不会自动初始化,其值是“当时的垃圾值”。未初始化就读取是未定义行为。务必声明时或之前赋初值(现代习惯写 int x = 0;)。

3. 运算符与表达式优先级、整数除法、自增前置后置、短路求值

类别运算符说明
算术+ - * / %% 为取模(仅整数)
关系== != < > <= >=结果为 0/1
逻辑&& || !短路求值
位运算& | ^ ~ << >>对二进制位操作
赋值= += -= *= /= %=右结合
自增/自减++ --前置先改后用,后置先用后改

3.1 整数除法会截断

int a = 5 / 2;          // a = 2 (小数部分被丢弃)
double b = 5.0 / 2;     // b = 2.5
double c = (double)5 / 2; // c = 2.5,先强制转换分子

3.2 前置++ 与后置++

int i = 0;
int x = i++;   // x=0, i=1 (先取值再自增)
int y = ++i;   // y=2, i=2 (先自增再取值)

3.3 短路求值

重要机制 && 左边为假时右边不再执行;|| 左边为真时右边不再执行。这常被用来写“防空”判断:if (p != NULL && p->val > 0),避免对空指针解引用。
优先级建议 运算符优先级复杂,不要靠记忆硬拼。多写括号:a & b == c 实际是 a & (b == c),极易错;写成 (a & b) == c 才是本意。

4. 控制流if/switch、for/while/do-while、break/continue

4.1 分支

if (score >= 90) {
    printf("优秀\n");
} else if (score >= 60) {
    printf("及格\n");
} else {
    printf("不及格\n");
}

switch (op) {
    case '+': result = a + b; break;
    case '-': result = a - b; break;
    case '*': result = a * b; break;
    default:  printf("未知运算\n");
}
switch 要点 每个 case 后通常要 break,否则会“贯穿”到下一个分支(fall-through)。有意为之的贯穿必须加注释说明;switch 的判别表达式只能是整型或枚举,不能是浮点/字符串。

4.2 循环

for (int i = 0; i < 5; i++) printf("%d ", i);   // 0 1 2 3 4

int n = 0;
while (n < 3) { printf("%d", n++); }              // 0 1 2

do { printf("至少执行一次\n"); } while (0);

for (int i = 0; i < 10; i++) {
    if (i == 3) continue;   // 跳过本次,i==3 不打印
    if (i == 8) break;      // 直接结束循环
    printf("%d ", i);
}
选择建议 循环次数已知用 for;条件先判断再执行用 while;至少要执行一次用 do-while(如菜单交互)。

5. 函数值传递、声明与定义、作用域、static

C 函数参数是值传递:调用时形参拿到的是实参的“副本”,函数内修改形参不影响外部的变量。想改外部变量,必须传它的地址(指针,见第 8 节)。

#include <stdio.h>

int add(int a, int b) {     // 这一行既是声明也是定义
    return a + b;
}

int main(void) {
    int s = add(3, 4);
    printf("3+4=%d\n", s);
    return 0;
}

5.1 先声明后使用(原型)

大项目里常把函数原型写在顶部或头文件,定义放后面/别的文件:

int add(int, int);     // 原型:参数名可省略,类型不能省

int main(void) { printf("%d\n", add(3,4)); return 0; }

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

5.2 static 的两种含义

  • 修饰局部变量:延长生命周期到程序结束,但作用域不变(函数内仍只它可见),只初始化一次。常用作“计数器”。
  • 修饰全局函数/变量:把可见性限制在本文件(内部链接),避免多文件重名冲突。
返回值 没有 return 却使用了返回值、或返回了局部变量的地址,都是未定义行为。函数若无需返回值,声明为 void

6. 数组连续内存、下标、数组退化、越界

数组是一段连续内存,元素类型相同、依次排列。下标从 0 开始。C 不提供任何越界检查,越界读写是未定义行为(可能悄悄破坏别的数据,也可能崩溃)。

int a[5] = {1, 2, 3, 4, 5};
for (int i = 0; i < 5; i++) printf("%d ", a[i]);

int len = sizeof(a) / sizeof(a[0]);   // 求元素个数(仅同作用域有效)
printf("\nlen=%d\n", len);

6.1 数组退化(很重要)

数组名在多数表达式中会“退化”为指向首元素的指针。一旦作为函数参数传入,函数内 sizeof 拿到的是指针大小而非数组大小,sizeof(a)/sizeof(a[0]) 失效——必须额外传长度。

void print_arr(int *a, int n) {   // 退化成指针,必须靠 n 知道长度
    for (int i = 0; i < n; i++) printf("%d ", a[i]);
}

6.2 多维数组

int m[2][3] = {{1,2,3}, {4,5,6}};
printf("%d\n", m[1][2]);   // 6
危险 sizeof(a)/sizeof(a[0]) 只能在数组“本名”还在的作用域用。传参后公式失效,这是初学者最常见的隐藏 bug。

7. 字符串char 数组 + \0、strlen/strcpy、安全函数

C 没有原生字符串类型,字符串是“以 \0(空字符,ASCII 0)结尾的 char 数组”。所有字符串函数都以 \0 判定结束。

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

int main(void) {
    char s[] = "hello";            // 实际占 6 字节: h e l l o \0
    printf("len=%zu\n", strlen(s)); // 5,不含 \0
    printf("cap=%zu\n", sizeof(s)); // 6,含 \0
    return 0;
}

7.1 常用字符串函数(<string.h>)

函数作用注意
strlen(s)长度(不含 \0)遇到 \0 才停,没 \0 会越界乱读
strcpy(dst,src)拷贝不检查 dst 容量,危险
strncpy(dst,src,n)限长拷贝可能不补 \0,要手动处理
strcmp(a,b)比较0 表示相等,非 0 不等
strcat(dst,src)拼接不检查容量,危险
高危 strcpy / strcat / sprintf 不检查目标缓冲区长度,极易造成缓冲区溢出(安全漏洞根源)。优先用 strncpy / snprintf
char buf[32];
snprintf(buf, sizeof(buf), "name=%s age=%d", name, age);  // 永不溢出

8. 指针入门地址、解引用、指针与数组、传指针改外部

指针是“存放内存地址的变量”。& 取地址,* 解引用(取地址里存的值)。这是 C 的灵魂,也是难点起点。

#include <stdio.h>

int main(void) {
    int x = 10;
    int *p = &x;             // p 指向 x(存的是 x 的地址)
    printf("x=%d\n", x);
    printf("*p=%d\n", *p);   // 解引用,得到 10
    *p = 20;                 // 通过指针改 x 本身
    printf("now x=%d\n", x); // 20
    printf("addr of x=%p\n", (void*)p);  // 打印地址用 %p
    return 0;
}

8.1 为什么需要指针

  • 修改外部变量:值传递改不了外部,传指针才能改(如 swap)。
  • 动态内存:运行时申请堆空间,必须靠指针持有(第 12 节)。
  • 高效传大数据:传数组/结构体只传指针(8 字节)而非整块拷贝。
  • 数据结构:链表、树、图都靠指针把节点“串”起来。

8.2 指针与数组的关系

int a[5] = {10,20,30,40,50};
int *p = a;                  // a 等价于 &a[0]
printf("%d %d\n", a[2], *(p+2));  // 30 30(等价)
p++;                        // 指针算术:移动到下一元素(地址 +sizeof(int))
读法 int *p 读作“p 是指向 int 的指针”。声明里的 * 只表示类型是指针;后面解引用用的 * 是“取该地址的值”的运算,两处不是一回事。

9. 结构体与联合体自定义类型、typedef、内存布局、位域

结构体把多个不同类型的数据打包成一个整体,是描述“现实对象”的基础(如学生、坐标、数据包)。

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

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

int main(void) {
    struct Student s = {1, "XiaoMing", 92.5};
    printf("%d %s %.1f\n", s.id, s.name, s.score);
    return 0;
}

9.1 typedef 简化

typedef struct Student {
    int id;
    char name[32];
} Stu;                 // 之后可写 Stu s; 而不必 struct Student s;

// C11 起也可直接:typedef struct { ... } Stu;(匿名结构起别名)

9.2 指针访问成员

struct Student s;
struct Student *p = &s;
p->id = 1;            // 等价于 (*p).id,-> 专用于指针访问成员
strcpy(p->name, "Li");

9.3 联合体 union 与位域

union union 的所有成员共享同一段内存,大小等于最大成员。常用于“同一数据多种解读”(如把一个 32 位 int 拆成 4 个字节)。
位域 结构体成员可用 unsigned int flag : 1; 指定占几位,适合硬件寄存器、协议字段等紧凑存储。
注意 结构体整体赋值是允许的(逐成员拷贝)。字符串数组要改内容用 strcpy,不能写 s.name = "abc"(数组不能整体赋值)。

10. 文件 I/O 基础FILE*、文本与二进制、错误处理

#include <stdio.h>

int main(void) {
    FILE *fp = fopen("note.txt", "w");   // 写模式
    if (fp == NULL) {                    // 一定要检查是否打开成功
        perror("fopen");
        return 1;
    }
    fprintf(fp, "Hello file!\n");
    fclose(fp);

    fp = fopen("note.txt", "r");
    char buf[128];
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        printf("%s", buf);
    }
    fclose(fp);
    return 0;
}

10.1 打开模式

模式含义
"r"读(文件须存在)
"w"写(不存在则建,存在则清空)
"a"追加
"rb"/"wb"二进制读/写(Windows 上文本/二进制有差异,跨平台必须加 b)

10.2 二进制读写

int data[3] = {1, 2, 3};
FILE *fp = fopen("d.bin", "wb");
fwrite(data, sizeof(int), 3, fp);   // 直接写内存块
fclose(fp);
铁律 fopen 可能失败(文件不存在、权限不够、磁盘满),永远检查返回值;用完必须 fclose,否则数据可能滞留缓冲区丢失、且泄漏句柄。

11. 指针进阶指针运算、指针数组、数组指针、函数指针雏形

11.1 指针算术

对指针 p+1 实际地址增加 sizeof(所指向类型),与数组下标等价:*(p+i) == p[i]

11.2 指针数组 vs 数组指针(经典易混)

int *arr[5];     // 指针数组:5 个“指向 int 的指针”
int (*p)[5];     // 数组指针:1 个“指向含 5 个 int 的数组”的指针

// 用法示例:字符串数组
const char *names[] = {"Alice", "Bob", "Cara"};
printf("%s\n", names[1]);   // Bob
辨析口诀 从变量名往外读,按优先级:[] 优于 *int *p[5] 先结合 [] 成数组,元素是 int*int (*p)[5] 括号强制先成指针,指向“5 个 int 的数组”。

11.3 多级指针

int x = 10;
int *p = &x;
int **pp = &p;        // 指向“int 指针”的指针
printf("%d\n", **pp); // 10
用途 二级指针常见于“函数要修改一个指针变量本身”,或字符串数组 char **argvmain 的命令行参数)。

12. 动态内存管理malloc/calloc/realloc/free、泄漏与悬空

栈上数组大小须编译期确定;运行时才知大小必须向申请:malloc / calloc / realloc / free(来自 <stdlib.h>)。

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

int main(void) {
    int n = 5;
    int *arr = malloc(n * sizeof(int));   // 不初始化(内容随机)
    // int *arr = calloc(n, sizeof(int)); // 全部清 0
    if (arr == NULL) { perror("malloc"); return 1; }

    for (int i = 0; i < n; i++) arr[i] = i * i;

    int *big = realloc(arr, 10 * sizeof(int)); // 扩容/缩容
    if (big == NULL) { free(arr); return 1; }  // 扩容失败:释放旧的,避免泄漏
    arr = big;

    free(arr);          // 用完释放
    arr = NULL;         // 置空,防止悬空指针
    return 0;
}
函数作用
malloc(size)分配 size 字节,不初始化
calloc(n, s)分配 n×s 字节并清零
realloc(p, new)调整 p 指向块的大小(可能搬家)
free(p)释放 p 指向的堆块
三大坑泄漏:分配后忘了 free,程序跑久了内存被吃光;② 悬空free 后继续用(use-after-free),行为未定义;③ 双重释放:同一指针 free 两次,会破坏堆管理结构。共同解药:释放即置 NULL,且 free(NULL) 是安全的(什么都不做)。

13. 函数指针与回调类型、作为参数、实现策略与 qsort

函数指针把“函数”当作数据传递,是实现回调、插件、策略模式的核心机制。

#include <stdio.h>

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

int calc(int (*op)(int,int), int x, int y) {
    return op(x, y);          // 通过函数指针调用
}

int main(void) {
    printf("%d\n", calc(add, 3, 4));  // 7
    printf("%d\n", calc(mul, 3, 4));  // 12
    return 0;
}
读法 int (*op)(int,int)op 是一个指针,指向“接收两个 int、返回 int 的函数”。
经典用例 标准库 qsort 就靠函数指针传“比较函数”对任意类型排序(第 17 节详解)。回调让“排序算法”与“比较规则”解耦——同一套排序,换个比较函数就能排不同规则。

14. 预处理与宏#define、宏函数、条件编译、头文件保护

#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))   // 宏函数:参数整体加括号!
#define PI 3.14159
#define DEBUG 1

int main(void) {
    printf("%d\n", MAX(2 + 3, 4));   // (2+3)>(4)?... 正确得 5
    printf("%.2f\n", PI);

#if DEBUG
    printf("debug info\n");
#endif
    return 0;
}

14.1 头文件保护(防重复包含)

// mylib.h
#ifndef MYLIB_H
#define MYLIB_H
int helper(int x);
#endif

14.2 条件编译的常见用途

  • 跨平台:#ifdef _WIN32 ... #else ... #endif
  • 调试开关:#ifdef DEBUG 打印日志
  • 功能裁剪:用宏开关启用/禁用某模块
宏的陷阱 宏只是文本替换,没有类型、没有求值顺序保护。#define SQ(x) x*x 遇到 SQ(a+1) 会变成 a+1*a+1,大错。务必每个参数、整体都加括号;复杂逻辑优先用 static inline 函数(有类型检查、可被优化)。

15. 位运算与或非异或、移位、状态标志压缩

位运算直接操作二进制位,常用于状态标志、数据压缩、加密、底层协议与性能优化。

#include <stdio.h>
#define FLAG_A (1 << 0)   // 0000 0001
#define FLAG_B (1 << 1)   // 0000 0010
#define FLAG_C (1 << 2)   // 0000 0100

int main(void) {
    int s = 0;
    s |= FLAG_A;          // 置位(打开 A)
    s |= FLAG_B;          // 打开 B
    s &= ~FLAG_A;        // 清位(关闭 A)
    if (s & FLAG_B) printf("B 开启\n");   // 测试位
    printf("翻转C: %d\n", s ^ FLAG_C);    // 异或翻转
    printf("A在第0位: %d\n", (s >> 0) & 1);
    return 0;
}
运算用途
x & (1<<n)测试第 n 位是否为 1
x |= (1<<n)置第 n 位为 1
x &= ~(1<<n)清第 n 位
x ^ (1<<n)翻转第 n 位
x << n / x >> n乘/除 2ⁿ(注意溢出与符号位)
注意 有符号数右移是“算术移位”还是“逻辑移位”由实现定义;涉及符号位位移时,建议用 unsigned 类型以保证可移植。

16. 递归自我调用、基准情形、栈与溢出

函数直接或间接调用自身。关键是必须有收敛到基准情形的出口,且每次递归都让问题规模变小,否则无限递归直到栈溢出。

#include <stdio.h>

long fib(int n) {
    if (n <= 1) return n;            // 基准情形(出口)
    return fib(n - 1) + fib(n - 2); // 递归
}

int main(void) {
    printf("fib(10)=%ld\n", fib(10)); // 55
    return 0;
}
效率提示 朴素斐波那契是指数级重复计算(fib(30) 会算上百万次)。真实项目用迭代记忆化(缓存已算结果)。递归深度过大会触发 stack overflow——能用循环的优先用循环,或用尾递归(编译器不一定优化)。

16.1 递归经典:汉诺塔

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) { printf("%c->%c\n", from, to); return; }
    hanoi(n - 1, from, aux, to);
    printf("%c->%c\n", from, to);
    hanoi(n - 1, aux, to, from);
}

17. 排序与查找冒泡/选择/快排思想、qsort、二分查找

17.1 手写冒泡排序(理解基础)

void bubble(int a[], int n) {
    for (int i = 0; i < n - 1; i++)
        for (int j = 0; j < n - 1 - i; j++)
            if (a[j] > a[j + 1]) {
                int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t;
            }
}

17.2 用标准库 qsort 对任意类型排序

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

int cmp(const void *a, const void *b) {
    int x = *(const int*)a, y = *(const int*)b;
    return (x > y) - (x < y);   // 升序;大于返1,小于返-1,等返0
}

int main(void) {
    int a[] = {5, 2, 9, 1, 7};
    qsort(a, 5, sizeof(int), cmp);
    for (int i = 0; i < 5; i++) printf("%d ", a[i]); // 1 2 5 7 9
    return 0;
}

17.3 二分查找(数组须有序)

int binary_search(int a[], int n, int key) {
    int lo = 0, hi = n - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;   // 防溢出写法
        if (a[mid] == key) return mid;
        if (a[mid] < key) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}
选型 小数组手写即可;通用场景优先 qsort。查找前若已排序用二分(O(log n));未排序只能线性查找(O(n))。

18. 数据结构实战(单链表)节点、头插/尾插、遍历、销毁

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

struct Node {
    int data;
    struct Node *next;
};

void push(struct Node **head, int v) {   // 头插法
    struct Node *n = malloc(sizeof(struct Node));
    n->data = v;
    n->next = *head;
    *head = n;
}

void free_list(struct Node *head) {      // 逐个释放,先存 next
    while (head) {
        struct Node *t = head;
        head = head->next;
        free(t);
    }
}

int main(void) {
    struct Node *head = NULL;
    push(&head, 3); push(&head, 2); push(&head, 1);
    for (struct Node *p = head; p; p = p->next) printf("%d ", p->data); // 1 2 3
    free_list(head);
    return 0;
}
核心纪律malloc 一个节点,必须在销毁时逐个 free;且 free 前先保存 next,否则链表断掉、后续节点泄漏。用二级指针 struct Node **head 才能在函数里修改头指针本身。

19. 多文件工程头文件声明、源文件实现、分离编译链接

真实项目按“头文件声明 + 源文件实现 + main 调用”拆分,再分开编译、最后链接,缩短重编译时间、隐藏实现细节。

// mathutil.h
#ifndef MATHUTIL_H
#define MATHUTIL_H
int add(int, int);
int max(int, int);
#endif

// mathutil.c
#include "mathutil.h"
int add(int a, int b) { return a + b; }
int max(int a, int b) { return a > b ? a : b; }

// main.c
#include <stdio.h>
#include "mathutil.h"
int main(void) { printf("%d\n", add(2, 3)); return 0; }
# 分开编译再链接
gcc -Wall -c mathutil.c -o mathutil.o
gcc -Wall -c main.c -o main.o
gcc mathutil.o main.o -o app
# 或一步到位
gcc -Wall mathutil.c main.c -o app
原则 头文件只放声明(函数原型、宏、类型定义、extern 变量),不放函数体实现(除非 static inline 或模板类场景);自定义头用 #include "xxx.h"(先搜当前目录),系统头用 <xxx.h>

20. 常见陷阱与规范段错误根源、未定义行为清单、工程规范

必避雷(未定义行为 Undefined Behavior)
  • 使用未初始化变量(值不确定)。
  • 数组/字符串越界(无运行时检查,破坏相邻内存)。
  • scanf("%d", x) 漏写 &,应为 &x
  • == 比较浮点数(应用差值 < eps)。
  • gets() 已废弃,永远别用;用 fgets()
  • 混用 mallocdelete,或 newfree
  • 解引用 NULL 或已释放的指针(段错误主因)。
  • 返回局部变量的指针/数组(函数结束后栈帧失效)。
  • 有符号/无符号混用比较(符号位被当作大正数)。
工程规范 编译必开 -Wall -Wextra;把警告当错误处理(-Werror 可选);始终检查指针/文件返回值;动态内存“谁分配谁释放”、释放即置 NULL;做好头文件保护;用 snprintf/strncpy 替代危险函数。

进阶训练场(含答案)

题 1:交换两个整数

写一个 swap 函数,能真正交换主函数里两个变量的值(值传递做不到,必须传地址)。

void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}
// 调用:swap(&x, &y);  必须传地址

题 2:统计字符串中数字字符个数

输入字符串,统计 '0'~'9' 的个数。

#include <stdio.h>
int count_digit(const char *s) {
    int c = 0;
    for (; *s; s++)            // *s 为 '\0' 时结束
        if (*s >= '0' && *s <= '9') c++;
    return c;
}

题 3:自己实现 strcmp

相等返回 0,否则返回首个不同字符的差值。

int my_strcmp(const char *a, const char *b) {
    while (*a && *a == *b) { a++; b++; }
    return (unsigned char)*a - (unsigned char)*b;
}

题 4:动态二维数组(n×m)

在堆上分配 n 行 m 列的 int 数组,能用 a[i][j] 访问,并正确释放。

int **make2d(int n, int m) {
    int **a = malloc(n * sizeof(int*));
    for (int i = 0; i < n; i++)
        a[i] = malloc(m * sizeof(int));
    return a;
}
void free2d(int **a, int n) {
    for (int i = 0; i < n; i++) free(a[i]);
    free(a);
}

题 5:反转单链表

迭代法反转单链表并返回新头。

struct Node* reverse(struct Node *head) {
    struct Node *prev = NULL, *cur = head;
    while (cur) {
        struct Node *nxt = cur->next;
        cur->next = prev;
        prev = cur;
        cur = nxt;
    }
    return prev;
}

题 6:用位运算判断奇偶 / 乘 2

不用 %*,用位运算判断一个 int 是奇数还是偶数,并计算 n×8。

int is_odd(int n)  { return n & 1; }      // 末位为1是奇数
int times8(int n)  { return n << 3; }      // 左移3位 = ×8

题 7:整数转字符串(itoa 简化版)

把一个非负整数转成十进制字符串(提示:先取各位数字,注意逆序)。

#include <string.h>
void my_itoa(int n, char *buf) {
    if (n == 0) { strcpy(buf, "0"); return; }
    char tmp[12]; int i = 0;
    while (n) { tmp[i++] = '0' + (n % 10); n /= 10; }
    for (int j = 0; j < i; j++) buf[j] = tmp[i - 1 - j];
    buf[i] = '\0';
}

题 8:回调实现通用“遍历并操作”

写一个函数 foreach(int* a, int n, void (*f)(int)),对每个元素调用回调 f。

#include <stdio.h>
void print_one(int x) { printf("%d ", x); }
void foreach(int *a, int n, void (*f)(int)) {
    for (int i = 0; i < n; i++) f(a[i]);
}
// 调用:foreach(arr, 5, print_one);

💡 连老师都不会教你的干货(工业级技巧)

为什么单列这一节学校里教 C 语法,但真在嵌入式、系统、性能敏感代码里写 C,靠的是下面这些"踩过坑才懂"的经验。每条都是实战总结。
  1. 未定义行为(UB)是 C 最大的坑。越界访问、解引用空/野指针、有符号整数溢出、free 后继续使用、修改字符串字面量——编译器不做检查,结果不可预测,且在优化后可能"凭空消失"。写 C 要时刻假设"UB 绝不该发生"。
  2. 数组退化。数组作为函数参数会退化为指针,sizeof(a) 拿到的是指针大小而非整个数组;传数组必须同时显式传长度。
  3. 指针与数组名不等价。数组名是常量地址,不能 a++;指针是变量。记住 a[i] 等价于 *(a+i)(甚至 i[a] 也合法但别这么写)。
  4. const 的"方向口诀"。int* const p 指针不可改;const int* p 指向内容不可改;const int* const p 都不可改。"const 靠近谁,谁不可改"。
  5. 有符号/无符号混算陷阱。unsigned 与 int 运算时,int 会被提升为 unsigned,于是 -1 < 0U 的结果竟是 false(经典离谱 bug)。比较前务必统一类型。
  6. 内存对齐与结构体 padding。struct 成员间可能有填充字节,sizeof 不等于各成员之和;做网络/文件序列化、或强制类型转换时要格外小心,可用 _Alignof / #pragma pack 控制。
  7. 宏只做文本替换。参数和整体都要加括号:#define SQ(x) ((x)*(x))。能用内联函数就别用宏,否则 SQ(i++) 这类副作用会让你怀疑人生。
  8. 初始化规则。静态/全局变量默认零初始化;但栈上的局部变量不初始化,值是随机垃圾——忘记初始化是常见崩溃源。
  9. 字符串字面量是只读的。它位于 .rodata 段,修改它属于 UB;要可写请用字符数组 char s[] = "..."
  10. 头文件守卫。#ifndef X_H / #define X_H / #endif 防止重复包含;现代多数编译器也支持更短的 #pragma once
  11. 变长数组(VLA,C99)在栈上分配,尺寸过大直接爆栈;生产环境慎用,或改回 malloc
  12. 理解链接。static 修饰全局/函数可限制为本文件可见;extern 声明定义在其他翻译单元。搞懂"翻译单元 + 链接阶段"才能避免重复定义链接错误。
  13. 整数提升(integer promotion)。char/short 在运算前会提升为 int,留意 signed char 与 unsigned char 的符号扩展差异——这是很多"明明是正的却变负"的元凶。
  14. restrict 关键字(C99)。告诉编译器两个指针不互为别名,有助于生成更快代码;但用错会让编译器做出错误优化,慎用。

自测题(点击展开答案)

  • Q1:int a[5] 作为函数参数传入后,sizeof(a) 得到的是?
    A:指针大小(如 8 字节),不是整个数组。数组已退化为指针,必须另传长度。
  • Q2:下面哪句会导致未定义行为?int a[3]={1,2,3}; a[3]=4;
    A:越界写入 a[3],C 不检查边界,结果是未定义行为(可能崩溃/数据损坏)。
  • Q3:malloc 返回 NULL 通常意味着?
    A:内存分配失败(如系统内存不足),必须检查返回值再使用,否则解引用 NULL 会崩溃。
  • Q4:#define SQ(x) x*x,调用 SQ(1+2) 得到?
    A:1+2*1+2 = 5,而非预期的 9。宏只做文本替换,参数与整体都该加括号。
  • Q5:浮点数比较为什么不能直接用 ==
    A:浮点存在精度误差(如 0.1+0.2 != 0.3),应使用 fabs(a-b) < 1e-9 这类容差比较。
  • Q6:函数想修改调用方的普通 int 变量,应传?
    A:它的地址 &x,形参为 int*。值传递只能改副本。
  • Q7:释放指针后正确的习惯是?
    A:立即置 NULL,防止悬空指针被误用;free(NULL) 是安全的。
  • Q8:strcpy 的主要风险是?
    A:不检查目标缓冲区长度,可能缓冲区溢出;应改用 snprintf/strncpy 并手动补 \0。
本课程为自用/教学整理的 C 语言系统资料,内容遵循 C11/C17 常用子集,手机与电脑端自适应。配套编译器练习效果最佳。 公网参考:cppreference.com · GNU C Manual。

🧪 动手实验室

不用从零敲代码:下面每个程序都已写好,点「▶ 运行」直接看结果;想改就在框里改,再点运行。手机、电脑都一样用。打开本页时会自动跑一次,方便你先看效果。

① 第一个程序:打印一句话

② 变量与运算(注意整数除法和取余)

③ 用循环求 1~100 的和

④ 数组:找出最大值

⑤ 指针:交换两个数(C 的精髓)

每个框都能用下拉切换语言、「↺ 重置」回到初始代码。想把代码存下来或写完整程序,去顶部「练习场」(支持 Python/Java/Go 等 10 种语言)。
🛠 在线练习