C语言诞生于20世纪70年代,是一种命令式和过程式的语言,不具备面向对象的特性 。它的抽象级别高于汇编语言,但比Java等更接近底层硬件 。
Hello World
老规矩我们还是先来看一下在C语言里是怎么写 Hello World 的:
1 |
|
其中:
#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):表示“空的”数据类型,常用于表示函数没有返回值或不需要参数 。
有/无符号
而除了 _Bool 和 char(char 是否带符号取决于编译器的具体实现)之外,所有标准数据类型默认都是有符号的 (signed) 。
无符号数不能表示负数,因此它拥有更大的正值范围 。
但C语言标准只对无符号数定义了溢出行为(会发生回绕) 。有符号数发生溢出是“未定义行为 (undefined behavior)” ,这在编程中是非常危险的,可能导致程序崩溃或产生不可预测的结果。(在binary exploitation中会见到integer overflow。)
固定宽度的整型
由于基础整型在不同操作系统下的长度可能不同(比如int有时是 16位,有时是 32位),为了写出跨平台兼容的稳定代码,推荐使用标准头文件 <stdint.h> 。它定义了具有精确位数的类型,例如int8_t(明确的 8位有符号)、uint32_t(明确的 32位无符号)、uint64_t 等 。
另外,通过引入<stdbool.h>,你可以使用更符合直觉的bool、true和false,它们实际上是底层整型1 和 0的语法糖 。
变量的声明、作用域与赋值
变量需要先声明后使用:变量在声明时会在内存中分配空间 。如果在声明时没有立刻赋值,它的初始值是未定义的(随机的内存垃圾值),并不会自动变为 0 。
1 | int a; |
作用域 (Scopes):变量的作用域由花括号 {} 决定。一个变量只在它被定义的那个大括号(block)内可见 。
赋值:可以用十进制(如 -2)、十六进制(如 0xDEADBEEF)赋值 。但如果数字以 0 开头(如 011),C语言会将其视为八进制数(Octal)。而字符字面量,比如 'A',编译器会自动将其转换为对应的 ASCII 数值 。
指针(Pointer)
指针是C的语言的核心内容。
注意ptr[i] 本质上是 *(ptr + i)。也就是说
1 | ptr[0] == *ptr |
const
将 const(常量)与指针结合时,根据位置的不同,意义完全不同 :
const TYPE* PTR或TYPE const* PTR:指向常量的指针。指针本身可以改指向别的地方,但不能通过这个指针去修改它指向的数据 。例子:
1
2
3
4
5
6
7
8int score1 = 80;
int score2 = 90;
// ptr 是一个指向 const int 的指针
const int* ptr = &score1;
ptr = &score2; // 允许:ptr 可以改换指向 score2
// *ptr = 100; // 编译报错:不能通过 ptr 去修改 score2 的值
TYPE * const PTR:常量指针。指针一旦指向了一个地址就不能再变了,但指向的那个数据是可以被修改的 。例子:
1
2
3
4
5
6
7
8int score1 = 80;
int score2 = 90;
// ptr 是一个 const 指针,指向 int
int * const ptr = &score1;
*ptr = 100; // 允许:可以通过 ptr 修改 score1 的值(此时 score1 变成 100)
// ptr = &score2; // 编译报错:ptr 的指向是常量,不能更改为指向 score2
const TYPE * const PTR:既不能修改指向的位置,也不能修改指向的数据 。例子:
1
2
3
4
5
6
7
8int score1 = 80;
int score2 = 90;
// ptr 是一个 const 指针,指向 const int
const int * const ptr = &score1;
// *ptr = 100; // 编译报错:不能修改数据
// ptr = &score2; // 编译报错:不能改变指向
数组
定义数组:
1 | int arr[3] = {1, 2, 3}; |
数组大小:sizeof(arr) / sizeof(arr[0])
1 | int arr[3] = {1, 2, 3}; |
所以数组长度就是
1 | size_t arr_len = sizeof(arr) / sizeof(arr[0]); |
控制结构
选择结构
if / else
1 | int score = 85; |
三元运算符 ? :
1 | int x_abs = (x >= 0) ? x : -x; |
如果问号?前面的条件满足,则走冒号:前的分支,反之则走冒号:后面的分支。
switch(适合离散值分支)
使用冒号 : 来标记分支。
使用
case 值:来定义入口。通常使用
break;来避免继续执行后面的分支。穿透现象 (Fallthrough):如果忘记写
break,程序会继续向下执行下一个case的代码,这通常会导致逻辑错误(但也可能被故意利用) 。
1 | switch (monat) { |
迭代结构
for
1 | for (int i = 0; i < n; i++) { |
这种写法i会遍历[0,n)的所有数。
如果需要遍历时也需要考虑n的情况,可以写成
1 | for (int i = 0; i <= n; i++) { |
会比较直观。
注意,循环的每一轮的执行顺序是这样的:
- 先判断条件:
i < n - 条件为真就执行循环体:
System.out.println(i); - 然后再执行更新表达式:
i++ - 回到 1)
while
1 | int i = 3; |
先判断条件,若为真则执行循环体,执行完后再次判断;若为假则跳过 。
- 可以用
break来退出当前循环 - 也可以用
continue跳过当前循环的剩余部分并直接开始下一轮循环
do-while
1 | int 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. 涉及变量的情况:
1 |
|
我们需要在格式化字符串( format string)里给定符合要求的格式化。
格式化占位符的语法如下:
1 | %[parameter][flags][field width][.precision][length]type |
- Parameter:指定用于格式化的参数位置(从1开始)
| 字符 | 说明 |
|---|---|
n$ |
其中n是参数位置 |
例子:
1 | printf("%2$d %1$d", 11, 22); |
- Flags:
| 标志 | 说明 |
|---|---|
- |
左对齐(默认是右对齐) |
+ |
总是显示正号或负号(例如 +10) |
(空格) |
正数前加空格,负数前加负号 |
0 |
用0填充未占满的宽度 |
# |
对于%o、%x、%X等,添加前缀(如0x);对于%f等,始终包含小数点 |
- Field Width:指定最小输出字符数,不足时用空格(或0)填充,如果要使用变量指定宽度,可以用 *。
例子:
1 | printf("%d", 42); |
- Precision:指定数字小数点后的位数或字符串的最大输出长度:
- 对于浮点数(如
%f):表示小数点后保留的位数,如%.2f - 对于字符串(如
%s):表示最大输出字符数,如%.5s - 可以使用
*表示由参数动态提供
- Length:指出浮点型参数或整型参数的长度
| 修饰符 | 说明 |
|---|---|
hh |
signed char 或 unsigned char |
h |
short 或 unsigned short |
l |
long 或 unsigned long |
ll |
long long 或 unsigned long long |
L |
long double(用于%Lf) |
z |
size_t |
t |
ptrdiff_t |
j |
intmax_t 或 uintmax_t |
例子:
1 |
|
- Type:也称转换说明(conversion specification/specifier),指定具体的数据类型,有以下选择
| 字符 | 说明 |
|---|---|
%d |
打印十进制整数(int) |
| %f | 打印浮点数(float/double) |
| %.2f | 打印浮点数,保留小数点后2位 |
| %s | 打印字符串(char*) |
| %c | 打印单个字符(char) |
| %x | 打印十六进制(小写) |
| %% | 输出一个百分号 % |
其中只有Type是必须要给的,其他均可以省略。
例子:
1 |
|
注意:在第二部分一定要给定变量,如果没有给,则会从错误的内存地址读取数据,导致不可预期的行为。
此外还有一个比较特殊的格式符:%n 。这个格式符会让 printf 把当前已经打印的字符数量写入 n 所在的地址。
比如说下面这个例子
1 |
|
n的值会被存储为5。
由于它的特殊性以及危险性,很多现代系统在 libc 中禁用了 %n,或者在格式化函数上增加了保护(如 glibc 中对 %n 的格式检查)。
内存分配
malloc
1 | void *malloc(size_t size); |
在堆(heap)上申请一块连续内存。
特点:
- 大小由
size指定 - 返回的内存未初始化
- 成功返回指针,失败返回
NULL - 需要手动
free()
例子:
1 | int *p = malloc(10 * sizeof(int)); |
calloc
1 | void *calloc(size_t nmemb, size_t size); |
在堆(heap)上申请一块连续内存。
和malloc基本一样,但是它会初始化分配的内存:
int元素会变成0char元素会变成'\0'
例子:
1 | int *p = calloc(5, sizeof(int)); |
realloc
调整已经在堆上分配的内存大小。
它通常和 malloc / calloc 配合使用。
特点:
- 可以把原来的堆内存变大或变小
- 可能原地扩容,也可能搬到新位置
- 返回的新指针可能和旧指针不同
- 失败时返回
NULL,原来的内存仍然有效 - 也需要最终
free()
例子:
1 | int *p = malloc(5 * sizeof(int)); |
适合:
- 动态数组扩容
- 运行过程中大小不断变化的数据结构
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 | float *p = aligned_alloc(32, 32 * 10); // size 必须是 32 的倍数 |
适合:
- 需要特殊对齐的场景
- 向量化指令
- 某些底层系统/高性能代码
C语言预处理器(C-Preprocessor)
预处理器的两大核心职能是:解析与展开宏定义,以及合并多个源文件。
在标准的C语言构建流水线中,整个转换过程分为四个独立步骤:
预处理 (Preprocessing):源文件
src.c经由预处理器cpp处理,生成扩展后的中间源文件src.i。编译 (Compilation):C编译器
cc1接收src.i文件,将其翻译为底层体系结构对应的汇编代码src.s。汇编 (Assembly):汇编器
as将src.s转化为机器码构成的方法目标文件src.o。链接 (Linking):最后,链接器
ld将一个或多个目标文件与标准库合并,生成最终的可执行文件exec。
宏定义与展开
预处理器通过 #define 指令建立文本级别的替换规则。可通过 #undef 指令解除已有的宏定义 。
需要特别注意的是,预处理器执行的是纯粹的文本替换,它不具备C语言的语法知识,也不会计算表达式的优先级,所以容易出现非预期的错误:
例子:
1 |
|
- 赋值语句
int a = NUMBER;会被预处理器替换为int a = 42;。 - 赋值语句
int b = MYNUM * 2;会被直接替换为int b = 2+3 * 2;。由于C语言中乘法优先级高于加法,最终b的计算结果为 8,而非预期中的 10 。
条件编译
条件编译允许开发者基于特定的预处理常量,控制哪些代码段应被移交给下一阶段的编译器,哪些应被丢弃。
例子:
1 |
|
在这个例子里,宏 MYFLAG 的值为 0(在逻辑判定中视为假)。因此,预处理器会剔除 #if MYFLAG 分支的内容,仅保留 #else 分支下的 const char c='B'; 供后续编译 。
例子:
1 |
|
使用 #if 0 和 #endif 是一种规范的屏蔽(注释)废弃代码的技术手段 。这种方式在屏蔽大块代码时比多行注释 /* ... */ 更加安全,因为它能够避免多行注释嵌套引发的词法解析错误。
文件包含指令 (#include)
#include 指令的物理意义等同于在指令所在位置执行复制与粘贴操作,将目标头文件的所有内容无缝插入当前文件中 。
例子:
1 |
在此存在两种语法的区分:
- 尖括号
< >:指示预处理器直接在系统标准库的头文件目录中检索该文件(例如<stdio.h>)。 - 双引号
" ":指示预处理器首先在当前项目的本地目录(Local Directory)中检索该自定义头文件,如果未找到,再退回系统目录查找 。