嵌入式 C 语言笔记
这篇用于记录 C 语言和嵌入式开发中容易混淆、但又经常用到的基础知识。
编译流程 “1” “2”-> “12”
1 | 只要两个字符串字面量相邻,中间只有空格、Tab、换行、注释,都会被自动拼接 |
flowchart TD
A["源文件 .c / .cpp"] --> B["预处理<br/>cpp / -E"]
B --> C["预处理后的文件 .i / .ii<br/>宏展开、头文件包含、条件编译"]
C --> D["词法分析<br/>生成记号流 token"]
D --> E["语法分析<br/>★ 相邻字符串字面量拼接 ★<br/>C 标准翻译阶段 6"]
E --> F["抽象语法树 AST"]
F --> G["语义分析<br/>类型检查、作用域解析"]
G --> H["中间代码生成<br/>如 LLVM IR、三地址码"]
H --> I["优化<br/>常量折叠、死代码消除等"]
I --> J["代码生成<br/>生成汇编代码 .s"]
J --> K["汇编 as<br/>转换为机器码"]
K --> L["目标文件 .o / .obj<br/>可重定位机器码 + 符号表"]
L --> M["链接 ld<br/>符号解析、重定位、合并段"]
M --> N["可执行文件 .exe / .elf / .out"]
style E fill:#ffcccc,stroke:#ff0000,stroke-width:2px
相邻字符串字面量拼接发生在编译过程中的语法分析阶段附近,对应 C 标准翻译阶段 6。
相邻字符串字面量拼接
多个字符串字面量只要相邻,中间只有空白字符或注释,就会被编译器自动拼接成一个字符串。
空格、Tab、换行都可以触发拼接。
1 | char *s1 = "hello" "world"; |
上面三个字符串最终都等价于:
1 | char *s = "helloworld"; |
常用于把长字符串拆成多行:
1 | const char *msg = |
但中间如果有逗号、运算符、分号等,就不会拼接。
1 | char *a = "hello", "world"; // 错误写法 |
宏展开后如果形成相邻字符串字面量,也会拼接。
1 |
|
堆和栈
在 C 语言程序运行时,常见的内存区域可以粗略分为代码区、全局区、堆和栈。其中堆和栈最容易混淆。
| 区域 | 主要用途 | 管理方式 | 常见问题 |
|---|---|---|---|
| 栈 | 局部变量、函数参数、返回地址 | 编译器自动管理 | 栈溢出、返回局部变量地址 |
| 堆 | 动态申请的内存 | 程序员手动申请和释放 | 内存泄漏、野指针、重复释放 |
栈
栈由编译器自动分配和释放,函数调用时会创建栈帧,函数返回后栈帧失效。
1 | void test(void) |
注意不要返回局部变量的地址,因为函数返回后该变量已经失效。
1 | int *bad_func(void) |
在嵌入式系统中,栈空间通常比较小。递归调用、大数组局部变量、过深的函数调用层级,都可能导致栈溢出。
1 | void bad_stack(void) |
堆
堆用于动态内存分配,常见函数有 malloc、calloc、realloc 和 free。
1 |
|
在嵌入式项目中,不建议随意使用动态内存。原因是堆空间有限,而且长时间运行后可能出现内存碎片。
如果必须使用堆,需要遵守几个原则:
- 谁申请,谁释放。
- 释放后立刻将指针置为
NULL。 - 避免重复释放同一块内存。
- 不要访问已经释放的内存。
- 尽量在初始化阶段完成申请,运行阶段少做动态申请。
指针
指针保存的是地址。理解指针时,可以把它拆成两个问题:
- 指针变量本身存放在哪里?
- 指针变量里面保存的地址指向哪里?
1 | int value = 10; |
这里:
value是一个int变量。p是一个指针变量。&value表示取value的地址。*p表示访问p指向的内容。
* 的两种含义
* 出现在不同位置时,含义不同。
| 写法 | 出现场景 | 含义 | 作用 |
|---|---|---|---|
int *p; |
定义变量时 | 表示 p 是一个指针变量,类型是 int * |
说明变量是指针类型 |
*p = 20; |
使用变量时 | 表示访问 p 指向地址里的数据 |
解引用 |
也就是说:
- 声明变量时,
*用来说明变量是指针类型。 - 使用指针时,
*用来解引用,也就是取出地址中保存的数据。
1 | int value = 10; |
执行后,value 的值会从 10 变成 20。
指针和数组
数组名在大多数表达式中会退化为指向首元素的指针。
1 | int arr[3] = {1, 2, 3}; |
指针加 1 不是地址简单加 1,而是移动到下一个元素。
1 | int arr[3] = {1, 2, 3}; |
typedef
typedef 用来给已有类型起一个新的名字,本质上是类型别名,与变量命名的作用一样。
1 | typedef unsigned char uint8_t; |
使用以后:
1 | uint8_t a; |
这样写的好处是代码更清晰,也方便在不同平台上统一数据类型。
typedef 和结构体
没有使用 typedef 时,定义结构体变量需要写 struct。
1 | struct 结构体名 |
使用 typedef 后,可以直接使用别名。
1 | typedef struct { |
typedef 和指针
typedef 和指针一起使用时,要注意 * 是别名的一部分。
1 | typedef int *PINT; |
这里 p1 和 p2 都是 int * 类型。
如果不用 typedef:
1 | int *p1, p2; |
这里 p1 是 int *,但 p2 是普通的 int。
所以指针类型别名虽然能简化代码,但也容易隐藏真实类型。嵌入式代码中如果不是特别必要,指针一般直接写出来更清楚。
typedef 和函数
typedef 可以给函数指针类型起别名,常用于回调函数。
| 形式 | 写法 | 含义 |
|---|---|---|
| 无参无返回函数 | void Init(void); |
函数名是 Init,无参数,无返回值 |
| 有参无返回函数 | void SetLed(int state); |
参数是 int,无返回值 |
| 无参有返回函数 | int GetKey(void); |
无参数,返回 int |
| 有参有返回函数 | int Add(int a, int b); |
参数是两个 int,返回 int |
| 无参无返回函数指针别名 | typedef void (*Func)(void); |
Func 是一种函数指针类型 |
| 有参无返回函数指针别名 | typedef void (*FuncParam)(void* param); |
FuncParam 是一种带 void* 参数的函数指针类型 |
| 无参有返回函数指针别名 | typedef int (*GetFunc)(void); |
GetFunc 是一种返回 int 的函数指针类型 |
| 有参有返回函数指针别名 | typedef int (*CalcFunc)(int a, int b); |
CalcFunc 是一种带参数且有返回值的函数指针类型 |
| 无参返回指针函数别名 | typedef void* (*GetPtrFunc)(void); |
GetPtrFunc 是一种无参数、返回 void* 的函数指针类型 |
| 有参返回指针函数别名 | typedef void* (*FindPtrFunc)(int id); |
FindPtrFunc 是一种有参数、返回 void* 的函数指针类型 |
例如:
1 | typedef void (*Func)(void); |
含义是:
Func表示一种函数指针类型,这种函数没有参数,也没有返回值。FuncParam表示一种函数指针类型,这种函数有一个void*参数,没有返回值。GetPtrFunc表示一种函数指针类型,这种函数没有参数,返回一个void*指针。FindPtrFunc表示一种函数指针类型,这种函数有一个int参数,返回一个void*指针。
使用时:
1 | static int value = 100; |
在嵌入式中,函数指针常用于:
- 回调函数。
- 中断处理接口。
- 驱动层注册函数。
- 状态机处理函数。
例如定义一个按键回调函数类型:
1 | typedef void (*KeyCallback)(void); |
指针常见错误
未初始化指针:
1 | int *p; |
空指针解引用:
1 | int *p = NULL; |
野指针:
1 | int *p = malloc(sizeof(int)); |
正确做法:
1 | int *p = malloc(sizeof(int)); |
指针和 const
const 和指针组合时,可以从右往左读。
1 | const int *p1; // 指向常量 int 的指针,不能通过 p1 修改值 |
常用判断:
const在*左边:不能通过指针修改指向的内容。const在*右边:指针变量本身不能被修改。
大小端
大小端描述的是多字节数据在内存中的存放顺序。
假设数据为 0x12345678:
| 类型 | 存放规则 | 内存顺序 |
|---|---|---|
| 大端 | 高字节放在低地址 | 12 34 56 78 |
| 小端 | 低字节放在低地址 | 78 56 34 12 |
常见 MCU 和 PC 平台多数使用小端模式;网络字节序通常使用大端模式。
小结
- 栈适合存放生命周期短、空间较小的局部变量。
- 堆适合动态申请内存,但嵌入式中要谨慎使用。
- 指针的核心是地址,使用前必须确认它指向有效内存。
- 大小端只影响多字节数据在内存中的字节顺序,不影响单字节数据。
- 通信协议中不要直接发送结构体内存,应按协议逐字节组包。