嵌入式C语言笔记

嵌入式 C 语言笔记

这篇用于记录 C 语言和嵌入式开发中容易混淆、但又经常用到的基础知识。

编译流程 “1” “2”-> “12”

1
2
3
4
5
6
7
8
9
只要两个字符串字面量相邻,中间只有空格、Tab、换行、注释,都会被自动拼接

"hello" "world"
-
"hello"
"world"
-
"hello" /* comment */ "world"
- 都等价于 "helloworld"
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
2
3
4
char *s1 = "hello" "world";
char *s2 = "hello"
"world";
char *s3 = "hello" /* comment */ "world";

上面三个字符串最终都等价于:

1
char *s = "helloworld";

常用于把长字符串拆成多行:

1
2
3
4
const char *msg =
"AT+MQTTCONN=0,"
"\"broker.example.com\","
"1883\r\n";

但中间如果有逗号、运算符、分号等,就不会拼接。

1
2
char *a = "hello", "world";   // 错误写法
char *b = "hello" + "world"; // 错误:两个字符串不能相加

宏展开后如果形成相邻字符串字面量,也会拼接。

1
2
3
4
#define STR1 "hello"
#define STR2 "world"

char *s = STR1 STR2; // 等价于 "helloworld"

堆和栈

在 C 语言程序运行时,常见的内存区域可以粗略分为代码区、全局区、堆和栈。其中堆和栈最容易混淆。

区域 主要用途 管理方式 常见问题
局部变量、函数参数、返回地址 编译器自动管理 栈溢出、返回局部变量地址
动态申请的内存 程序员手动申请和释放 内存泄漏、野指针、重复释放

栈由编译器自动分配和释放,函数调用时会创建栈帧,函数返回后栈帧失效。

1
2
3
4
5
void test(void)
{
int a = 10; // 局部变量,通常位于栈上
int b = 20;
}

注意不要返回局部变量的地址,因为函数返回后该变量已经失效。

1
2
3
4
5
int *bad_func(void)
{
int value = 100;
return &value; // 错误:返回局部变量地址
}

在嵌入式系统中,栈空间通常比较小。递归调用、大数组局部变量、过深的函数调用层级,都可能导致栈溢出。

1
2
3
4
void bad_stack(void)
{
uint8_t buffer[4096]; // 在 MCU 中可能非常危险
}

堆用于动态内存分配,常见函数有 malloccallocreallocfree

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdlib.h>

void test(void)
{
int *p = malloc(sizeof(int));

if (p != NULL) {
*p = 123;
free(p);
p = NULL;
}
}

在嵌入式项目中,不建议随意使用动态内存。原因是堆空间有限,而且长时间运行后可能出现内存碎片。

如果必须使用堆,需要遵守几个原则:

  • 谁申请,谁释放。
  • 释放后立刻将指针置为 NULL
  • 避免重复释放同一块内存。
  • 不要访问已经释放的内存。
  • 尽量在初始化阶段完成申请,运行阶段少做动态申请。

指针

指针保存的是地址。理解指针时,可以把它拆成两个问题:

  • 指针变量本身存放在哪里?
  • 指针变量里面保存的地址指向哪里?
1
2
3
4
int value = 10;
int *p = &value;

*p = 20; // 通过指针修改 value

这里:

  • value 是一个 int 变量。
  • p 是一个指针变量。
  • &value 表示取 value 的地址。
  • *p 表示访问 p 指向的内容。

* 的两种含义

* 出现在不同位置时,含义不同。

写法 出现场景 含义 作用
int *p; 定义变量时 表示 p 是一个指针变量,类型是 int * 说明变量是指针类型
*p = 20; 使用变量时 表示访问 p 指向地址里的数据 解引用

也就是说:

  • 声明变量时,* 用来说明变量是指针类型。
  • 使用指针时,* 用来解引用,也就是取出地址中保存的数据。
1
2
3
4
int value = 10;
int *p = &value; // 这里的 * 表示 p 是 int 类型指针

*p = 20; // 这里的 * 表示访问 p 指向的变量 value

执行后,value 的值会从 10 变成 20

指针和数组

数组名在大多数表达式中会退化为指向首元素的指针。

1
2
3
4
5
6
int arr[3] = {1, 2, 3};
int *p = arr;

printf("%d\n", arr[0]);
printf("%d\n", *p);
printf("%d\n", *(p + 1));

指针加 1 不是地址简单加 1,而是移动到下一个元素。

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

p = p + 1; // 实际地址增加 sizeof(int)

typedef

typedef 用来给已有类型起一个新的名字,本质上是类型别名,与变量命名的作用一样。

1
2
3
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;

使用以后:

1
2
3
uint8_t  a;
uint16_t b;
uint32_t c;

这样写的好处是代码更清晰,也方便在不同平台上统一数据类型。

typedef 和结构体

没有使用 typedef 时,定义结构体变量需要写 struct

1
2
3
4
5
6
7
8
9
10
11
struct 结构体名
{
成员表列
}变量名表列;

struct Student {
int age;
char name[20];
};

struct Student stu1;

使用 typedef 后,可以直接使用别名。

1
2
3
4
5
6
typedef struct {
int age;
char name[20];
} Student;

Student stu1;

typedef 和指针

typedef 和指针一起使用时,要注意 * 是别名的一部分。

1
2
3
typedef int *PINT;

PINT p1, p2;

这里 p1p2 都是 int * 类型。

如果不用 typedef

1
int *p1, p2;

这里 p1int *,但 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
2
3
4
typedef void (*Func)(void);
typedef void (*FuncParam)(void* param);
typedef void* (*GetPtrFunc)(void);
typedef void* (*FindPtrFunc)(int id);

含义是:

  • Func 表示一种函数指针类型,这种函数没有参数,也没有返回值。
  • FuncParam 表示一种函数指针类型,这种函数有一个 void* 参数,没有返回值。
  • GetPtrFunc 表示一种函数指针类型,这种函数没有参数,返回一个 void* 指针。
  • FindPtrFunc 表示一种函数指针类型,这种函数有一个 int 参数,返回一个 void* 指针。

使用时:

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
static int value = 100;

void init_task(void)
{
printf("init\n");
}

void handle_event(void* param)
{
printf("param = %p\n", param);
}

void* get_value_ptr(void)
{
return &value;
}

void* find_value_ptr(int id)
{
if (id == 1) {
return &value;
}

return NULL;
}

Func func = init_task;
FuncParam func_param = handle_event;
GetPtrFunc get_ptr = get_value_ptr;
FindPtrFunc find_ptr = find_value_ptr;

func();
func_param(NULL);
printf("%p\n", get_ptr());
printf("%p\n", find_ptr(1));

在嵌入式中,函数指针常用于:

  • 回调函数。
  • 中断处理接口。
  • 驱动层注册函数。
  • 状态机处理函数。

例如定义一个按键回调函数类型:

1
2
3
4
5
6
typedef void (*KeyCallback)(void);

void key_register_callback(KeyCallback callback)
{
callback();
}

指针常见错误

未初始化指针:

1
2
int *p;
*p = 10; // 错误:p 指向未知地址

空指针解引用:

1
2
int *p = NULL;
*p = 10; // 错误:访问 NULL

野指针:

1
2
3
int *p = malloc(sizeof(int));
free(p);
*p = 10; // 错误:p 指向的内存已经释放

正确做法:

1
2
3
4
5
6
7
int *p = malloc(sizeof(int));

if (p != NULL) {
*p = 10;
free(p);
p = NULL;
}

指针和 const

const 和指针组合时,可以从右往左读。

1
2
3
4
const int *p1;        // 指向常量 int 的指针,不能通过 p1 修改值
int const *p2; // 和 p1 等价
int *const p3 = &x; // 指针本身是常量,不能再指向别处
const int *const p4; // 指针和指向的内容都不能修改

常用判断:

  • const* 左边:不能通过指针修改指向的内容。
  • const* 右边:指针变量本身不能被修改。

大小端

大小端描述的是多字节数据在内存中的存放顺序。

假设数据为 0x12345678

类型 存放规则 内存顺序
大端 高字节放在低地址 12 34 56 78
小端 低字节放在低地址 78 56 34 12

常见 MCU 和 PC 平台多数使用小端模式;网络字节序通常使用大端模式。

小结

  • 栈适合存放生命周期短、空间较小的局部变量。
  • 堆适合动态申请内存,但嵌入式中要谨慎使用。
  • 指针的核心是地址,使用前必须确认它指向有效内存。
  • 大小端只影响多字节数据在内存中的字节顺序,不影响单字节数据。
  • 通信协议中不要直接发送结构体内存,应按协议逐字节组包。