Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

C语言诞生于20世纪70年代,是一种命令式和过程式的语言,不具备面向对象的特性 。它的抽象级别高于汇编语言,但比Java等更接近底层硬件 。

Hello World

老规矩我们还是先来看一下在C语言里是怎么写 Hello World 的:

1
2
3
4
5
6
#include <stdio.h>

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

其中:

  • #include <stdio.h>:引入标准输入输出库,printf 就在这里面定义。

    include的部分一般被叫做头文件。

    注意,C是用#include把头文件复制进代码。#include 是预处理器,会文本替换/复制内容)

  • int main(void):程序的主函数,是程序执行的起点。

  • printf("Hello, World!\n");:向屏幕输出 Hello, World!\n 表示换行。

  • return 0;:表示程序正常结束。

    main函数的返回值通常代表程序的退出状态。

数据类型

C语言中的数据类型并不总是固定大小的,标准只规定了它们的最小保证大小

  • 字符型与布尔型
    • _Bool:布尔类型,标准保证至少有1 Bit可用 。
    • char:字符型,其大小被严格定义为1 Byte(字节) 。标准规定它至少包含 8 Bit 。
  • 整型 (Integer)
    • short (int):至少16Bit 。
    • int:至少16Bit ,但在现代常见系统(如LP64架构)中通常是 32 Bit 。
    • long (int):至少32Bit ,通常是64Bit 。
    • long long (int):至少64Bit 。
  • 浮点型:包含float(通常为32 Bit)和 double(通常为 64 Bit) 。
  • 空类型 (void):表示“空的”数据类型,常用于表示函数没有返回值或不需要参数 。

有/无符号

而除了 _Boolcharchar 是否带符号取决于编译器的具体实现)之外,所有标准数据类型默认都是有符号的 (signed)

无符号数不能表示负数,因此它拥有更大的正值范围 。

但C语言标准只对无符号数定义了溢出行为(会发生回绕) 。有符号数发生溢出是“未定义行为 (undefined behavior)” ,这在编程中是非常危险的,可能导致程序崩溃或产生不可预测的结果。(在binary exploitation中会见到integer overflow。)

固定宽度的整型

由于基础整型在不同操作系统下的长度可能不同(比如int有时是 16位,有时是 32位),为了写出跨平台兼容的稳定代码,推荐使用标准头文件 <stdint.h> 。它定义了具有精确位数的类型,例如int8_t(明确的 8位有符号)、uint32_t(明确的 32位无符号)、uint64_t 等 。

另外,通过引入<stdbool.h>,你可以使用更符合直觉的booltruefalse,它们实际上是底层整型10的语法糖 。

变量的声明、作用域与赋值

变量需要先声明后使用:变量在声明时会在内存中分配空间 。如果在声明时没有立刻赋值,它的初始值是未定义的(随机的内存垃圾值),并不会自动变为 0

1
2
int a;
int a = 0;

作用域 (Scopes):变量的作用域由花括号 {} 决定。一个变量只在它被定义的那个大括号(block)内可见 。

赋值:可以用十进制(如 -2)、十六进制(如 0xDEADBEEF)赋值 。但如果数字以 0 开头(如 011),C语言会将其视为八进制数(Octal)。而字符字面量,比如 'A',编译器会自动将其转换为对应的 ASCII 数值 。

指针(Pointer)

指针是C的语言的核心内容。

注意ptr[i] 本质上是 *(ptr + i)。也就是说

1
2
3
4
ptr[0] == *ptr
ptr[3] == *(ptr + 3)

ptr[index] == index[ptr]

const

const(常量)与指针结合时,根据位置的不同,意义完全不同 :

  1. const TYPE* PTRTYPE const* PTR指向常量的指针。指针本身可以改指向别的地方,但不能通过这个指针去修改它指向的数据 。

    例子:

    1
    2
    3
    4
    5
    6
    7
    8
    int score1 = 80;
    int score2 = 90;

    // ptr 是一个指向 const int 的指针
    const int* ptr = &score1;

    ptr = &score2; // 允许:ptr 可以改换指向 score2
    // *ptr = 100; // 编译报错:不能通过 ptr 去修改 score2 的值
  1. TYPE * const PTR常量指针。指针一旦指向了一个地址就不能再变了,但指向的那个数据是可以被修改的 。

    例子:

    1
    2
    3
    4
    5
    6
    7
    8
    int score1 = 80;
    int score2 = 90;

    // ptr 是一个 const 指针,指向 int
    int * const ptr = &score1;

    *ptr = 100; // 允许:可以通过 ptr 修改 score1 的值(此时 score1 变成 100)
    // ptr = &score2; // 编译报错:ptr 的指向是常量,不能更改为指向 score2
  1. const TYPE * const PTR:既不能修改指向的位置,也不能修改指向的数据 。

    例子:

    1
    2
    3
    4
    5
    6
    7
    8
    int score1 = 80;
    int score2 = 90;

    // ptr 是一个 const 指针,指向 const int
    const int * const ptr = &score1;

    // *ptr = 100; // 编译报错:不能修改数据
    // ptr = &score2; // 编译报错:不能改变指向

数组

定义数组

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

int arr[] = {1, 2, 3}; //也可以省略长度, 编译器会自动推断长度为 3.

数组大小sizeof(arr) / sizeof(arr[0])

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

sizeof(arr) == 12
sizeof(arr[0]) == 4

所以数组长度就是

1
size_t arr_len = sizeof(arr) / sizeof(arr[0]);

控制结构

选择结构

if / else

1
2
3
4
5
6
7
8
int score = 85;
if (score >= 90) {
printf("A");
} else if (score >= 80) {
printf("B");
} else {
printf("C");
}

三元运算符 ? :

1
2
int x_abs = (x >= 0) ? x : -x;
// 计算绝对值

如果问号?前面的条件满足,则走冒号:前的分支,反之则走冒号:后面的分支。

switch(适合离散值分支)

使用冒号 : 来标记分支。

  • 使用 case 值: 来定义入口。

  • 通常使用break;来避免继续执行后面的分支。

  • 穿透现象 (Fallthrough):如果忘记写 break,程序会继续向下执行下一个 case 的代码,这通常会导致逻辑错误(但也可能被故意利用) 。

1
2
3
4
5
6
7
8
9
switch (monat) {
case 1:
tage = 31;
break; // 必须有 break
case 2:
tage = 28;
break;
// ...
}

迭代结构

for

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

这种写法i会遍历[0,n)的所有数。

如果需要遍历时也需要考虑n的情况,可以写成

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

会比较直观。

注意,循环的每一轮的执行顺序是这样的:

  1. 先判断条件: i < n
  2. 条件为真就执行循环体:System.out.println(i);
  3. 然后再执行更新表达式:i++
  4. 回到 1)

while

1
2
3
4
5
int i = 3;
while (i > 0) {
printf("%d\n", i);
i--;
}

先判断条件,若为真则执行循环体,执行完后再次判断;若为假则跳过 。

  • 可以用break来退出当前循环
  • 也可以用continue跳过当前循环的剩余部分并直接开始下一轮循环

do-while

1
2
3
4
int x = 0;
do {
printf("执行一次\n");
} while (x > 0);

至少执行一次循环体,因为条件检查在末尾 。

格式化字符串

printf 是 C 标准库函数,用于格式化文本并将其输出到标准输出。

它的语法相比于其他的编程语言(比如说Python,C++之类的)会复杂很多。

printf的参数主要分成2部分:格式化字符串以及变量名。其中格式化字符串( format string)可以参考以下定义(我个人觉得这个定义比较清楚):

The format string is a character string which contains two types of objects: plain characters, which are simply copied to the output channel, and conversion specifications, each of which causes conversion and printing of arguments.

(来源:https://ocaml.org/manual/5.0/api/Printf.html)

我们来分情况看一下printf的具体语法:

1. 只有字符串的情况:

1
2
3
4
5
6
# include <stdio.h>
int main(void)
{
printf("Hello World!\n"); // \n表示换行
return 0;
}

这种情况下就非常简单,直接在引号里输入希望输出的内容即可。

2. 涉及变量的情况:

1
2
3
4
5
6
7
# include <stdio.h>
int main(void)
{
int i = 10;
printf("%d\n", i); // %d是输出控制符,d 表示十进制,后面的 i 是输出参数*
return 0;
}

我们需要在格式化字符串( format string)里给定符合要求的格式化。

格式化占位符的语法如下:

1
%[parameter][flags][field width][.precision][length]type

- Parameter:指定用于格式化的参数位置(从1开始)

字符 说明
n$ 其中n是参数位置

例子:

1
2
printf("%2$d %1$d", 11, 22);
// 会输出 22 11

- Flags

标志 说明
- 左对齐(默认是右对齐)
+ 总是显示正号或负号(例如 +10)
(空格) 正数前加空格,负数前加负号
0 用0填充未占满的宽度
# 对于%o%x%X等,添加前缀(如0x);对于%f等,始终包含小数点

- Field Width:指定最小输出字符数,不足时用空格(或0)填充,如果要使用变量指定宽度,可以用 *

例子:

1
2
3
4
5
printf("%d", 42);
// 会输出 " 42" (前面有3个空格)

printf("%*d", 5, 42);
// 会输出 " 42" (前面有3个空格)

- Precision:指定数字小数点后的位数或字符串的最大输出长度:

  • 对于浮点数(如 %f):表示小数点后保留的位数,如 %.2f
  • 对于字符串(如 %s):表示最大输出字符数,如 %.5s
  • 可以使用 * 表示由参数动态提供

- Length:指出浮点型参数或整型参数的长度

修饰符 说明
hh signed charunsigned char
h shortunsigned short
l longunsigned long
ll long longunsigned long long
L long double(用于%Lf
z size_t
t ptrdiff_t
j intmax_tuintmax_t

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>

int main() {
signed char a = -5;
printf("%hhd\n", a);
// 会输出 "-5"

short s = 32000;
printf("%hd\n", s);
// 会输出 "32000"

long l = 123456789L;
printf("%ld\n", l);
// 会输出 "123456789"

long long ll = 9223372036854775807LL;
printf("%lld\n", ll);
// 会输出 "9223372036854775807"

long double ld = 3.141592653589793238L;
printf("%Lf\n", ld);
// 会输出 "3.141593"(默认保留6位小数)

size_t sz = 100;
printf("%zu\n", sz);
// 会输出 "100"

ptrdiff_t diff = -8;
printf("%td\n", diff);
// 会输出 "-8"

intmax_t im = 9223372036854775807;
printf("%jd\n", im);
// 会输出 "9223372036854775807"

return 0;
}

- Type:也称转换说明(conversion specification/specifier),指定具体的数据类型,有以下选择

字符 说明
%d 打印十进制整数(int)
%f 打印浮点数(float/double)
%.2f 打印浮点数,保留小数点后2位
%s 打印字符串(char*)
%c 打印单个字符(char)
%x 打印十六进制(小写)
%% 输出一个百分号 %

其中只有Type是必须要给的,其他均可以省略。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

int main() {
int i = 123;
float pi = 3.14159;
char letter = 'A';
char name[] = "hello";
int hex = 255;

printf("整数:%d\n", i);
printf("浮点数(默认):%f\n", pi);
printf("浮点数(保留两位):%.2f\n", pi);
printf("字符串:%s\n", name);
printf("字符:%c\n", letter);
printf("十六进制:%x\n", hex);
printf("百分号:%%\n");

return 0;
}

注意:在第二部分一定要给定变量,如果没有给,则会从错误的内存地址读取数据,导致不可预期的行为。

此外还有一个比较特殊的格式符:%n 。这个格式符会让 printf 把当前已经打印的字符数量写入 n 所在的地址。

比如说下面这个例子

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int n;
printf("hello%n", &n);

return 0;
}

n的值会被存储为5。

由于它的特殊性以及危险性,很多现代系统在 libc 中禁用了 %n,或者在格式化函数上增加了保护(如 glibc 中对 %n 的格式检查)。

内存分配

malloc

1
void *malloc(size_t size);

堆(heap)上申请一块连续内存。

特点:

  • 大小由 size 指定
  • 返回的内存未初始化
  • 成功返回指针,失败返回 NULL
  • 需要手动 free()

例子:

1
2
3
4
5
int *p = malloc(10 * sizeof(int));
if (p == NULL) {
/* 处理错误 */
}
free(p);

calloc

1
void *calloc(size_t nmemb, size_t size);

堆(heap)上申请一块连续内存。

malloc基本一样,但是它会初始化分配的内存:

  • int 元素会变成 0

  • char 元素会变成 '\0'

例子:

1
2
3
4
5
6
7
8
int *p = calloc(5, sizeof(int));
if (p == NULL) {
return 1;
}

/* p[0] 到 p[4] 初始都是 0 */

free(p);

realloc

调整已经在堆上分配的内存大小

它通常和 malloc / calloc 配合使用。

特点:

  • 可以把原来的堆内存变大或变小
  • 可能原地扩容,也可能搬到新位置
  • 返回的新指针可能和旧指针不同
  • 失败时返回 NULL原来的内存仍然有效
  • 也需要最终 free()

例子:

1
2
3
4
5
6
7
8
9
10
11
int *p = malloc(5 * sizeof(int));
if (p == NULL) return 1;

int *tmp = realloc(p, 10 * sizeof(int));
if (tmp == NULL) {
free(p); // 原来的 p 还有效
return 1;
}
p = tmp;

free(p);

适合:

  • 动态数组扩容
  • 运行过程中大小不断变化的数据结构

alloca

1
void *alloca(size_t size);

栈(stack) 上临时分配内存。

特点:

  • 分配出来的内存在当前函数返回时自动失效
  • 不用 free
  • 通常很快,但不标准、可移植性较差
  • 分配过大可能直接导致栈溢出
  • 生命周期只到当前函数结束

例子:

1
int *p = alloca(10 * sizeof(int));

aligned_alloc

堆上分配“按指定对齐方式对齐”的内存

特点:

  • 本质上也是堆内存
  • 需要 free()
  • 返回地址满足指定对齐要求,比如 16 字节、32 字节、64 字节对齐
  • 常用于 SIMD、缓存优化、硬件接口、页对齐场景
  • 在标准 C 里,size 必须是 alignment 的整数倍

例子:

1
2
3
4
5
float *p = aligned_alloc(32, 32 * 10);  // size 必须是 32 的倍数
if (p == NULL) {
/* 错误处理 */
}
free(p);

适合:

  • 需要特殊对齐的场景
  • 向量化指令
  • 某些底层系统/高性能代码

C语言预处理器(C-Preprocessor)

预处理器的两大核心职能是:解析与展开宏定义,以及合并多个源文件。

在标准的C语言构建流水线中,整个转换过程分为四个独立步骤:

  1. 预处理 (Preprocessing):源文件 src.c 经由预处理器 cpp 处理,生成扩展后的中间源文件 src.i

  2. 编译 (Compilation):C编译器 cc1 接收 src.i 文件,将其翻译为底层体系结构对应的汇编代码 src.s

  3. 汇编 (Assembly):汇编器 assrc.s 转化为机器码构成的方法目标文件 src.o

  4. 链接 (Linking):最后,链接器 ld 将一个或多个目标文件与标准库合并,生成最终的可执行文件 exec

宏定义与展开

预处理器通过 #define 指令建立文本级别的替换规则。可通过 #undef 指令解除已有的宏定义 。

需要特别注意的是,预处理器执行的是纯粹的文本替换,它不具备C语言的语法知识,也不会计算表达式的优先级,所以容易出现非预期的错误

例子:

1
2
3
4
5
6
7
#define NUMBER 42
#define MYNUM 2+3

int a = NUMBER;
int b = MYNUM * 2;

#undef MYNUM
  • 赋值语句 int a = NUMBER; 会被预处理器替换为 int a = 42;
  • 赋值语句 int b = MYNUM * 2; 会被直接替换为 int b = 2+3 * 2; 。由于C语言中乘法优先级高于加法,最终 b 的计算结果为 8,而非预期中的 10 。

条件编译

条件编译允许开发者基于特定的预处理常量,控制哪些代码段应被移交给下一阶段的编译器,哪些应被丢弃。

例子:

1
2
3
4
5
6
7
#define MYFLAG 0

#if MYFLAG
const char c='A';
#else
const char c='B';
#endif

在这个例子里,宏 MYFLAG 的值为 0(在逻辑判定中视为假)。因此,预处理器会剔除 #if MYFLAG 分支的内容,仅保留 #else 分支下的 const char c='B'; 供后续编译 。

例子:

1
2
3
#if 0
int x=42; // auskommentierter Code
#endif

使用 #if 0#endif 是一种规范的屏蔽(注释)废弃代码的技术手段 。这种方式在屏蔽大块代码时比多行注释 /* ... */ 更加安全,因为它能够避免多行注释嵌套引发的词法解析错误。

文件包含指令 (#include)

#include 指令的物理意义等同于在指令所在位置执行复制与粘贴操作,将目标头文件的所有内容无缝插入当前文件中 。

例子:

1
2
#include <system_header.h> // Copy-paste Inhalte von system_header.h an diese Stelle
#include "local_header.h" // Copy-paste Inhalte von local_header.h an diese Stelle

在此存在两种语法的区分:

  • 尖括号 < >:指示预处理器直接在系统标准库的头文件目录中检索该文件(例如 <stdio.h>)。
  • 双引号 " ":指示预处理器首先在当前项目的本地目录(Local Directory)中检索该自定义头文件,如果未找到,再退回系统目录查找 。