1、volatile关键字,volatile修饰指针与修饰指针指向内容的区别
volatile 关键字核心作用:告诉编译器不要做优化,每次访问都必须真实从内存读取 / 写入,不要使用寄存器缓存的值。
常见场景:寄存器映射、中断变量、多任务共享变量。
cpp
// 1. volatile 修饰指针变量本身
volatile uint8_t * p;
// 2. volatile 修饰指针指向的内容(目标内存)
uint8_t * volatile p;
// 3. 两者都修饰:指针本身、指向内容都是易变
volatile uint8_t * volatile p;
(1) volatile uint8_t *p;
volatile 修饰p 指向的数据,指针变量p本身不是 volatile。
*p:每次读 / 写,访问真实内存,不缓存。
p(指针本身,存地址):编译器可以优化、放到寄存器缓存。
典型用途:外设寄存器地址,寄存器硬件会自动变化。
cpp
// STM32 寄存器标准写法示例
#define GPIOA_MODER (*((volatile uint32_t *)0x40020000))
把 0x40020000 强制转为volatile uint32_t*;*访问寄存器,硬件会改变寄存器值,不能被编译器优化掉读写。
(2)uint8_t * volatile p;
volatile 修饰指针变量 p 本身,p 存的地址是易变;指向的内容*p不被 volatile 保护。
p:指针变量本身,每次必须从内存读取地址,不能放在寄存器缓存。
*p:访问指向的数据,编译器可以优化缓存。
场景:指针变量会被中断函数修改,指针本身的值(地址)会随时变。
(3)volatile uint8_t * volatile p;
指针本身 和 指向内容,全部禁止优化,全部访问真实内存。中断里既会改指针存的地址,又会改指针指向的数据时使用。
| 写法 | volatile 保护对象 | 重点行为 | 典型场景 |
|---|---|---|---|
volatile T *p |
*p 指向的内容 | 内容每次读内存;指针 p 地址可被寄存器缓存 | 硬件寄存器、外设 IO |
T * volatile p |
指针变量 p 本身 | p 存的地址每次读内存;*p 内容可优化 | 中断修改指针变量本身 |
volatile T * volatile p |
指针 + 指向内容 | 全部禁止优化 | 双重易变,少用 |
代码示例看编译器优化差异
示例 1:volatile 修饰指向内容(寄存器)
cpp
volatile uint32_t *reg = (volatile uint32_t*)0x40020000;
while( (*reg & 0x01) == 0 )
{
// 等待硬件置位bit0
}
没有 volatile:编译器发现循环内没有修改*reg,会只读取一次放到寄存器,变成死循环,不会反复读硬件寄存器。
加 volatile:每次循环都去 0x40020000 真实内存读取。
示例 2:uint8_t * volatile p 指针本身被中断修改
cpp
uint8_t * volatile buf_ptr; // 指针本身会在中断里修改地址
void isr_handler(void)
{
buf_ptr = rx_buffer; // 中断修改指针保存的地址
}
void main(void)
{
while(1)
{
if(buf_ptr != NULL) // 每次必须从内存拿buf_ptr的值,不能缓存
{
//处理数据
}
}
}
如果不加 volatile,main 函数中编译器可能把buf_ptr加载进寄存器,看不到中断修改后的指针地址。
⚠️注意:这里只是指针buf_ptr本身受保护,*buf_ptr的数据访问依旧会被编译器优化;如果接收缓冲区数据也会中断修改,需要写成:
cpp
volatile uint8_t * volatile buf_ptr;
常见踩坑点
1、❌ volatile 不做原子保护!
volatile 只阻止编译器优化,不能防止多任务 / 中断的读写撕裂。多字节变量依旧需要关中断或者互斥锁保护。volatile ≠ 线程安全。
2、❌ 错误写法
cpp
volatile uint8_t * p;
*p = 0xff;
uint8_t val = *p;
✅正确写法
cpp
uint8_t * volatile p;
*p = 0xff;
uint8_t val = *p;
⚠️指针 p 每次读内存,但*p读取可以被优化缓存。
3、寄存器定义为什么用(*((volatile uint32_t *)ADDR))
强制把地址转为指向 volatile 数据的指针;解引用访问寄存器硬件,避免编译器优化掉读写操作。
cpp
volatile 离谁近修饰谁
volatile T *p; // volatile靠近T,修饰*p(指向内容)
T * volatile p; // volatile靠近p,修饰指针p本身
2、static修饰局部变量、全局变量、函数分别有什么效果
static 有 3 个使用场景:局部变量(函数内)、全局变量、函数,核心两个特性:
1、作用域(可见范围)
2、存储位置 & 生命周期
内存分布:
1、普通局部变量:栈 stack,函数调用时创建,函数退出销毁
2、 static 变量:静态存储区(data/bss 段),整个程序运行期间一直存在
1. static 修饰局部变量(函数内部)
cpp
void func(void)
{
static int a = 0;
int b = 0;
a++;
b++;
printf("a=%d, b=%d\n", a, b);
}
特性
1、存储位置:静态存储区,不在栈上,生命周期:程序整个运行周期,函数退出不会销毁。
2、初始化:只执行 1 次! 第一次调用函数初始化,后续调用跳过初始化语句。
3、作用域仍然是函数内部:外部无法访问 a。
4、普通局部变量b:每次进函数赋值 0,函数结束栈回收。
调用示例:
cpp
func(); // a=1 b=1
func(); // a=2 b=1
func(); // a=3 b=1
适用场景:函数需要保存上一次调用状态;状态机计数;只初始化一次的缓冲。
⚠️坑:多线程 / 中断下,static 局部变量非原子,会出现数据撕裂,需要互斥保护。
2、static 修饰全局变量(函数外面)
cpp
static int g_val;
(1)普通全局变量:
存储:静态区
作用域:整个工程,其他.c 文件可以通过extern引用访问
(2)加 static之后:
存储位置不变,依旧静态存储区,生命周期整个程序。
作用域被限制为本文件,其他 .c 文件无法访问,extern 也拿不到;对外隐藏该变量。
✅用途:模块私有全局变量,防止跨文件命名冲突,模块化封装。
嵌入式写驱动 .c 文件里,硬件状态、缓冲数组全部写成 static 全局,不要对外暴露。
3、static 修饰函数
cpp
static void driver_init(void)
{
}
(1)普通函数:默认全局,整个工程任意.c 都可以调用。
(2)static 函数:
作用域仅限当前 .c 文件,别的源文件不能调用,头文件也不能声明给外部使用。
函数链接属性变为内部链接。
不改变函数栈行为。
✅用途:模块内部私有工具函数,不对外接口,减少全局符号表,避免命名冲突。
驱动内部底层工具函数,全部 static;对外功能才写非 static,放到.h 声明。
| 修饰对象 | 生命周期 | 作用域 | 存储区 | 关键点 |
|---|---|---|---|---|
| 普通局部变量 | 函数调用期间 | 函数内 | 栈 stack | 函数退出销毁,每次调用重新初始化 |
| static 局部变量 | 整个程序运行 | 函数内 | 静态区 data/bss | 初始化仅一次;函数退出值保留 |
| 普通全局变量 | 整个程序运行 | 全部工程(extern 可跨文件访问) | 静态区 | 全局共享,容易命名冲突 |
| static 全局变量 | 整个程序运行 | 仅限本.c 文件 | 静态区 | 模块私有,外部不可访问 |
| 普通函数 | --- | 全部工程可调用 | 代码段 text | 外部文件可调用 |
| static 函数 | --- | 仅限本.c 文件调用 | 代码段 text | 内部私有函数 |
3、const指针区分 const int*p、int *const p
const 指针两种核心形式
口诀:const 靠近谁,谁就不能改
cpp
const int *p; // ① const修饰 *p (指向的内容)
int *const p; // ② const修饰 p (指针变量本身)
① const int *p; 等价于 int const *p;
const 修饰指针指向的数据 ,*p 只读;指针p本身可变。
- ✅
p = &var2;合法:指针可以改指向别的地址 - ❌
*p = 100;非法:不能修改指针指向内存里的值
示例:
cpp
int a = 10;
int b = 20;
const int *p = &a;
p = &b; // OK,指针可以指向b
*p = 99; // 编译报错!不能修改 *p 的内容
嵌入式场景:函数入参,防止函数内部修改外部传入的数据。
cpp
// 约定:buf指向的数据只读,函数不能改写buf内容
void print_buf(const uint8_t *buf, int len);
② int *const p;
const 修饰指针变量 p 本身 ;指针保存的地址不能修改;但是指向的内容*p可以修改。
❌ p = &var2; 非法:指针不能再指向别的地址,指针一经初始化,地址固定死
✅ *p = 100; 合法:可以修改指针指向内存的数据
示例:
cpp
int a = 10;
int b = 20;
int *const p = &a;
p = &b; // 编译报错!指针p地址不可修改
*p = 99; // OK,可以修改a的值
使用场景:固定硬件地址指针,指针地址固定,允许读写该地址上的数据。
③ 双重 const:const int *const p;
指针本身不能改,指向内容也不能改,双向只读。
cpp
const int *const p = &a;
p = &b; // 报错
*p = 100; // 报错
典型:指向只读硬件 ROM 区域。
| 写法 | p(指针存的地址) | *p(指向内容) | 典型用途 |
|---|---|---|---|
const int *p |
可修改 | 不可修改 | 函数参数,保护源数据不被改写 |
int *const p |
不可修改 | 可修改 | 固定指针地址,例如固定外设地址 |
const int *const p |
不可修改 | 不可修改 | ROM 常量,只读地址 |
4、数组与指针区别,sizeof(数组)、sizeof(指针)结果差异
数组和指针不是等价类型 ;数组名绝大多数场景隐式退化为首元素指针,唯独 sizeof(数组)、&数组名 保留原生数组类型。
1、基础示例
cpp
uint8_t arr[10];
uint8_t *p = arr;
sizeof(arr) → 10:数组,得到整个数组占用字节数
sizeof(p) → 8(64 位编译器) / 4(32 位单片机 Keil):得到指针变量本身大小,和指向多大数组无关
STM32 (32 位):指针永远占 4 字节,不管指向 1 字节还是 1000 字节数组。
2、数组名什么时候退化为指针
数组名只有在下面场景,隐式转换成首元素指针:
- 数组名赋值给指针变量
p = arr; - 函数传参
func(arr); arr + 1、*arr、arr[i]运算
✅唯独两个地方数组不会退化:sizeof(数组名)、&数组名
&arr 和 &arr 0 的区别
cpp
uint8_t arr[10];
&arr[0]; // 首元素地址,类型 uint8_t*
&arr; // 整个数组的地址,类型 uint8_t (*)[10]
二者地址数值一样,但是类型不同,指针加减步长不一样:
cpp
&arr[0] + 1 // +1 字节,跳到下一个元素
&arr + 1 // +10 字节,跳过整个数组
3、函数参数大坑(嵌入式高频踩坑)
函数形参写 uint8_t arr[],编译器会把它完全当成指针!
cpp
void test(uint8_t arr[])
{
// 这里 arr 本质就是指针!
sizeof(arr); // 32位MCU结果 = 4,不是数组真实长度!
}
void test2(uint8_t *arr)
{
sizeof(arr); // 同样等于4
}
uint8_t buf[20];
test(buf);
⚠️坑:在函数内部,无法通过sizeof获取传入数组的长度,必须额外传长度参数。
工程写法:
cpp
void test(uint8_t arr[], uint16_t len);
4、外部声明:数组和指针的欺骗(extern 经典坑)
.c 文件定义:
cpp
uint8_t buf[100];
❌错误 .h 声明
cpp
extern uint8_t *buf;
编译不报错,运行直接 HardFault! 定义是数组(内存放 100 字节数据);声明成指针,程序会把数组前 4 字节当成指针地址去解引用。
✅正确头文件声明
cpp
extern uint8_t buf[];
5、数组指针 vs 指针数组(极易搞混)
cpp
uint8_t *arr[5]; // 指针数组:arr是数组,5个元素,每个元素是uint8_t*指针
sizeof(arr); // 32位:5*4 = 20字节
uint8_t (*p)[5]; // 数组指针:p是指针,指向一个大小为5的uint8_t数组
sizeof(p); // 32位:4字节
记忆:看括号,()优先级高于[]
*arr[5]:[]先结合 → 数组,里面存指针(*p)[5]:*先结合 → 指针,指向数组
| 表达式 | 32 位环境 (Keil STM32) | 说明 |
|---|---|---|
uint8_t arr[10]; sizeof(arr) |
10 | 整个数组字节大小 |
sizeof(arr[0]) |
1 | 单个元素大小 |
uint8_t *p; sizeof(p) |
4 | 指针本身大小 |
函数形参 uint8_t arr[] 内部 sizeof (arr) |
4 | 已经退化为指针 |
sizeof(&arr) |
4 | 取数组地址,本质是指针 |
uint8_t *arr[10]; sizeof(arr) |
40 | 指针数组,10 个指针 |
uint8_t (*p)[10]; sizeof(p) |
4 | 数组指针,只是指针 |
嵌入式编程要点
(1)不要企图在被调用函数内部 sizeof 求数组长度,一定要传 len 入参。
(2).h中extern外部数组,必须写extern uint8_t buf\[\],不能写指针。
(3) arri 语法等价于 *(arr+i);数组下标本质只是语法糖,指针也可以用下标写法 p2。
(4)字符串:char str\[\] = "hello";数组存字符串;char *str = "hello";指针指向 ROM 常量字符串。
cpp
char str1[] = "abc";
char *str2 = "abc";
sizeof(str1); // 4 ('a','b','c','\0')
sizeof(str2); // 4 指针大小
5、结构体内存对齐原则,为什么要对齐,不对齐会产生什么现象
一、内存对齐三大原则(GCC/Keil MDK‑ARM,ARM32)
默认对齐规则,没有 #pragma pack 干预时:
1、每个成员的起始地址,必须是该成员自身大小的整数倍
uint16_t(2 字节):只能放在地址 0,2,4,6...
uint32_t(4 字节):只能放在地址 0,4,8...
2、结构体整体大小,必须等于结构体中最大基础成员大小的整数倍。不够则末尾填充填充字节 (padding,空洞)。
3、结构体嵌套结构体:把内部结构体看作一个整体,对齐模数取内部结构体的最大成员。
示例 1
cpp
typedef struct
{
uint8_t a; // 1字节
uint32_t b; // 4字节
uint8_t c; // 1字节
}Test_t;
内存排布:
cpp
0: a
1: padding(空洞)
2: padding
3: padding
4‑7: b
8: c
9: padding
10: padding
11: padding
a占 1 字节;下一个b是 4 字节,必须从 4 的倍数地址开始,所以 1‑3 填充 3 字节。
c占 1 字节,到地址 8;结构体最大成员 4 字节,总大小必须是 4 倍数。8+1=9,末尾补 3 字节。
sizeof(Test_t) = 12,不是 1+4+1=6。
示例 2 调换顺序,减少 padding
cpp
typedef struct
{
uint32_t b;
uint8_t a;
uint8_t c;
}Test_t;
排布:0‑3:b;4:a;5:c;6‑7 padding。
sizeof(Test_t) = 8。
工程技巧:结构体内部把大类型放前面,小类型放后面,可以节省内存。
嵌套结构体示例
cpp
typedef struct{
uint8_t x;
uint32_t y;
}Inner_t; // sizeof=8
typedef struct{
uint8_t m;
Inner_t s;
}Outer_t;
Inner_t 最大成员 4 字节,Inner 整体对齐模数 4。
m 在 0;s 必须从 4 倍数地址开始,1‑3 padding;s 占 8 字节;总大小 4+8 =12。
二、为什么 CPU 需要内存对齐?
1、硬件访问总线特性(ARM32)
ARM CPU 访问 4 字节uint32_t,硬件希望从4 字节对齐地址读取,一次总线周期读出完整 32bit 数据。
对齐访问:1 次总线读操作。
非对齐访问:需要 2 次总线读取,CPU 内部再拼接数据,效率下降。
2、部分硬件直接不支持非对齐访问
老 ARM 内核(Cortex‑M0/M0+)硬件没有非对齐硬件支持,一旦发生非对齐访问 → HardFault 死机。
Cortex‑M3/M4/M7 支持非对齐访问,但会性能损耗。
三、不对齐会发生什么现象
分两种内核情况:
- Cortex‑M0/M0+(无硬件非对齐支持)
结构体强制压缩 #pragma pack(1),然后直接指针强转访问结构体成员。
👉 直接 HardFault,程序跑飞死机。
- Cortex‑M3/M4/F4 等,硬件支持非对齐
(1) 读取数据错误,数值错乱:CPU 分两次读内存,拼接字节,大小端 + 错位导致读到乱码。
(2)性能下降:多次总线访问。
(3) DMA 坑!DMA 硬件控制器不支持非对齐访问,源 / 目的地址非对齐,传输出来数据全部错乱。DMA 是外设直接访问内存,绕过 CPU,不具备非对齐拼接能力。这是嵌入式高频 bug。
⚠️典型坑代码:二进制协议解析,直接把缓冲区指针强转为结构体指针。
cpp
uint8_t rx_buf[] = {0x11,0x22,0x33,0x44,0x55};
typedef struct{
uint8_t a;
uint32_t b;
}Msg_t;
Msg_t *msg = (Msg_t*)rx_buf; // rx_buf[0]是uint8_t,b起始地址为1,非对齐!
msg->b; // M0+直接HardFault;M4读到错误数值;DMA绝对不能用这个msg指针。
❌不要直接将字节缓冲区强制转结构体指针解析协议!这是嵌入式代码里常见隐患。
✅正确做法:逐字节拷贝,或者使用 packed 结构体,同时注意 DMA 不能使用 packed 结构体指针。
四、修改对齐:#pragma pack
#pragma pack(1) 按 1 字节对齐(取消对齐,无 padding)
cpp
#pragma pack(1)
typedef struct
{
uint8_t a;
uint32_t b;
uint8_t c;
}Test_t;
#pragma pack() // 恢复默认对齐!!不要忘记恢复
此时sizeof(Test_t) = 6,没有填充字节。
⚠️pack (1) 结构体注意事项
- 成员访问会非对齐;M0/M0 + 访问成员会 HardFault。
- packed 结构体的指针严禁交给 DMA,DMA 硬件不支持非对齐。
- pack 作用域要配对,
#pragma pack()恢复默认,否则后面所有结构体全部 1 字节对齐,埋下隐患。
| 现象 | 结果 | |
|---|---|---|
| 默认对齐 | 产生 padding 空洞,结构体变大;CPU/DMA 访问安全,效率高 | |
| #pragma pack(1) | 无 padding,结构体紧凑省内存;产生非对齐地址 | M0 + 访问成员 HardFault;M3/M4 性能损耗;DMA 禁止使用 |
| 缓冲区强转结构体指针解析协议 | 极易触发非对齐,数据乱码 / 死机 |
6、手写宏实现求两数最大值,需要考虑宏副作用
什么是宏副作用
宏是简单文本直接替换,不是函数。
如果传入带自增、自减、函数调用的表达式,会被展开多次,导致表达式执行多次,产生错误结果。
错误示例(最常见写法,有严重副作用):
cpp
// ❌错误版本,有副作用
#define MAX(a,b) ((a)>(b) ? (a) : (b))
测试:
cpp
int x = 2, y =3;
int res = MAX(x++, y++);
展开后:
cpp
int res = ((x++)>(y++) ? (x++) : (y++));
条件判断执行一次x++、y++;如果走分支,还会再执行一次自增。 变量会被自增 2 次,逻辑完全错误。
方案 1:GCC 扩展语句表达式(嵌入式 GCC 编译器,STM32 GCC 可用)
语句表达式 ({ ...; }),在宏内部定义临时变量保存 a、b 的值,只计算一次,消除副作用。
cpp
// ✅安全MAX宏,消除副作用,GCC支持
#define MAX(a, b) ({ \
typeof(a) _a = (a); \
typeof(b) _b = (b); \
(_a > _b) ? _a : _b; \
})
typeof(a):获取 a 的变量类型,定义临时变量_a、_b,a、b 只计算一次。
测试副作用场景:
cpp
int x=2,y=3;
int ret = MAX(x++, y++);
// x只+1,y只+1,结果正确,不会重复自增
注意:Keil MDK‑ARM(AC5 编译器)不支持语句表达式;AC6 支持 GCC 扩展。
方案 2:C11 泛型选择 _Generic(标准 C,无编译器扩展)
C11 标准,没有 typeof,借助_Generic,可移植,写法复杂,工程少用。
方案 3:兼容 Keil AC5,纯标准 C,无扩展
AC5 不支持语句表达式、typeof。 想要规避副作用:宏本身很难完美做到,两种选择:
1、直接写 inline 内联函数,完全消除副作用。
cpp
static inline int max(int a, int b)
{
return a > b ? a : b;
}
缺点:类型固定;要支持不同类型,需要写多套 inline 函数。
2、约定使用规范:调用宏的时候,不要传入带有++、--、函数调用的表达式。
对比:MIN 宏同理
cpp
#define MIN(a, b) ({ \
typeof(a) _a = (a); \
typeof(b) _b = (b); \
(_a < _b) ? _a : _b; \
})
关键注意点(宏的括号坑)
即使普通宏,每个参数、整个表达式外面必须加括号。
❌错误:
cpp
#define MAX(a,b) a>b ? a : b
int v = MAX(1+2,3+4);
//展开:1+2>3+4 ?1+2:3+4,运算符优先级出错
✅每一个参数(a)、(b)都带括号,整体表达式包裹。
7、malloc所属内存区域,free之后为什么建议将指针置NULL
1.malloc 分配的内存属于哪块区域
malloc()申请的内存来自 堆 (heap)。
栈 (stack):局部变量,函数自动分配释放,大小小,自动回收。
堆 (heap):由用户手动malloc申请、free释放;全局动态内存。
全局 / 静态区:全局变量、static 变量,程序启动分配,程序结束释放。
代码段 (.text):程序指令;常量段 (.rodata) 字符串常量。
嵌入式 RTOS(FreeRTOS):malloc底层很多时候调用 RTOS 的堆管理,堆大小由堆配置宏决定;如果堆耗尽,malloc 返回NULL。
cpp
uint8_t *p = malloc(100);
p本身是局部变量,存放在栈上;
p保存的地址,指向堆内存。
2.free 到底做了什么
cpp
free(p);
- 把这块堆内存标记为空闲,还给堆管理器,可以被后续 malloc 重新分配使用。
- ✅不会把 p 指针变量本身置 NULL!
- ❗不会把内存里面的数据清零,原来的数据还残留在内存。
free 之后,指针p仍然保存原来那块已经释放掉的内存地址,这个就叫野指针(悬挂指针 dangling pointer)。
cpp
uint8_t *p = malloc(100);
free(p);
// p != NULL;p仍然保存原来堆地址,野指针!
*p = 0x55; // ❌非法访问已经释放的堆内存,未定义行为!
现象:
这块内存已经归还堆管理器,可能马上被别的malloc拿走使用。
继续读写*p:内存踩踏、随机崩溃、数据乱码,偶现 bug,极难复现调试。
3. 为什么 free 之后建议指针赋值为 NULL
cpp
free(p);
p = NULL;
避免野指针,防止二次 free
cpp
free(p);
free(p); // ❌重复free,未定义行为,堆损坏!
如果 free 后置 NULL:
cpp
free(p);
p = NULL;
free(p); // free(NULL) C标准是安全的,什么都不做,不会破坏堆
后续 if 判断可以有效拦截
cpp
if(p != NULL)
{
//使用p
}
如果不置 NULL,if(p!=NULL)判断依然成立,但是指向已经释放的内存,进入分支造成非法访问。
⚠️重点:置 NULL 不能修复已经 free 的内存,仅仅是把指针变量清空,规避野指针的风险,内存本身依旧已经释放。
4. 经典错误案例
cpp
void fun(void)
{
uint8_t *p = malloc(10);
free(p);
//忘记 p=NULL;
}
//函数退出,p栈变量销毁,外部拿不到p,野指针隐患留在函数内部还好;
//但如果p是全局指针/静态指针,free之后不置NULL,风险极大!
全局指针场景危害最大:
cpp
uint8_t *g_buf;
void alloc_buf(void){
g_buf = malloc(100);
}
void release_buf(void){
free(g_buf);
// 忘记 g_buf = NULL;
}
release 之后,g_buf不是 NULL,依然指向已经释放堆;别的地方判断if(g_buf != NULL)就会错误使用已经释放的内存。
5. 封装一个安全释放宏(嵌入式常用)
cpp
#define SAFE_FREE(p) do{ free(p); (p)=NULL; }while(0)
使用do{}while(0)包裹,保证宏在 if/else 无大括号场景语法正确。
cpp
SAFE_FREE(g_buf);
8、MCU上电到main完整启动流程,启动柜文件、链接脚本作用
核心文件:启动文件 (.s 汇编)、链接脚本 (.ld/.scat 分散加载文件)
cpp
上电 → CPU复位 → 取复位向量 → 执行启动文件Reset_Handler →
设置栈、堆 → 拷贝data段、清零bss段 → 调用SystemInit()配置时钟 →
调用__main → 初始化库、全局变量构造 → 跳转main()
⚠️Cortex‑M 特点:复位后MSP 主栈指针先从向量表第一个位置加载 ,再进入复位中断服务函数Reset_Handler。
1. 上电复位硬件阶段
- 电源稳定,MCU 产生复位信号。
- CPU 从0x00000000(向量表基地址) 读取前两个 32bit 值:
0x00000000:MSP 初始栈顶地址 ,设置主栈指针SP = MSP0x00000004:复位中断入口地址,PC 跳转到Reset_Handler,进入启动文件汇编。
Cortex‑M 向量表第一个不是指令,是栈指针!这和 ARM‑A 系列不一样。
2. 启动文件 (.s) 作用(汇编文件,如startup_stm32f407xx.s)
启动文件是复位后执行的第一段程序,汇编编写。
Reset_Handler内部关键步骤:
- 从链接脚本符号获取 栈 (__initial_sp)、堆 (__heap_base、__heap_limit) 的地址。
.data段拷贝 :把 Flash 里的初始化数据拷贝到 RAM。.data:已经初始化的全局变量int g_a=10;,存储在 Flash,运行时要复制到 RAM。.bss段清零:把 RAM 中 bss 区域全部置 0。.bss:未初始化全局 / 静态变量int g_b;,运行时 RAM 必须初始化为 0。- 调用
SystemInit():配置系统时钟、PLL,设置系统主频(STM32)。 - 调用库函数入口
__main(Keil),不是用户 main。 __main做完 C 库初始化、全局对象初始化,最后调用用户的main()。
启动文件还定义:整个中断向量表 ,所有中断的入口函数,弱定义WEAK中断处理函数。
cpp
void HardFault_Handler(void) __attribute__ ((weak));
用户不重写,就执行默认死循环;用户自己实现同名函数,覆盖弱定义。
启动文件不做的事:不决定内存放在哪里,地址由链接脚本决定。
启动文件核心职责总结
- 设置 MSP 栈指针
- data 段拷贝、bss 段清零(C 语言运行环境准备)
- 中断向量表定义
- 调用 SystemInit、跳转到 main 入口
3. 链接脚本作用
Keil MDK:分散加载脚本 .scat;GCC:.ld链接脚本。
编译器只关心代码语法;链接脚本决定:代码 / 数据放在 Flash 哪块、RAM 哪块,定义各个段的起始地址、大小。
主要划分两大存储区域:
- FLASH(ROM) :存放
.text代码段、.rodata常量字符串;还有.data段的初始化副本。 - RAM :
.data运行副本、.bss、栈 (stack)、堆 (heap)。
链接脚本会输出很多符号,启动汇编文件直接引用这些符号,例如:
cpp
__data_load__ // Flash中data段加载地址
__data_start__ // RAM中data段运行地址
__data_end__
__bss_start__
__bss_end__
__initial_sp //栈顶地址
__heap_base
__heap_limit
启动文件的汇编,就是拿这些符号,完成搬运 data、清零 bss。
链接脚本核心职责
- 定义 Flash、RAM 的起始地址与大小(MCU 芯片手册的存储映射)。
- 把各个
.o目标文件的各个段 (.text/.rodata/.data/.bss) 分配到 ROM/RAM。 - 导出全局符号,供启动文件汇编使用。
- 决定栈、堆在 RAM 中的位置与大小。
- 决定向量表存放的 Flash 地址。
如果修改 RAM 大小、增大堆、重定向向量表,修改链接脚本。
4. 各个段含义梳理
| 段 | 存储位置 (加载) | 运行位置 | 内容 |
|---|---|---|---|
| .text | Flash | Flash | 程序代码指令 |
| .rodata | Flash | Flash | const 常量、字符串常量 |
| .data | Flash | RAM | 初始化非 0 全局 /static 变量;Flash 存原始值,启动拷贝到 RAM |
| .bss | 不占 Flash | RAM | 未初始化全局 /static 变量,启动时清零 |
| stack 栈 | --- | RAM | 局部变量,函数调用,SP 向下生长 |
| heap 堆 | --- | RAM | malloc 动态内存 |
.data特别:加载域在 Flash,运行域在 RAM,启动文件负责搬运。
5. 完整流程梳理
- 上电复位 ,硬件读取向量表前两个字,初始化 MSP 栈指针,PC 进入
Reset_Handler(启动文件)。 - 启动文件根据链接脚本导出符号:
- 将 Flash 中
.data段初始化数据拷贝到 RAM 的.data区域。 - 将 RAM 中
.bss段全部清零。
- 将 Flash 中
- 调用
SystemInit(),配置 RCC/PLL,系统时钟初始化。 - 调用
__main:C 运行库初始化,处理全局变量初始化。 - 跳转用户
main()函数,应用程序正式运行。
注意:main 函数执行之前,全局变量已经完成初始化,就是启动文件做 data 拷贝、bss 清零实现。
9、GPIO推挽输出、开漏输出区别,各自适用场景
1. 推挽输出(Push-Pull)
内部PMOS+NMOS双管结构,可主动输出高/低电平,支持灌电流、拉电流,驱动能力强。无需外部电阻,适合同电压板内通信。
场景:LED、UART、SPI、普通IO控制。
禁忌:多个推挽引脚禁止并联,高低对冲会烧毁IO。
2. 开漏输出(Open-Drain)
内部仅NMOS下拉管,无上拉管。只能主动拉低电平,高电平依赖外部4.7k~10k上拉电阻 。支持线与逻辑、电平转换。
场景:I2C总线、多设备线与通信、3.3V转5V电平转换。
| 项目 | 推挽输出 (Push‑Pull) | 开漏输出 (Open‑Drain) |
|---|---|---|
| 内部结构 | PMOS+NMOS | 仅 NMOS,无上管 |
| 输出高电平 | 内部直接输出 VDD | 内部高阻,依赖外部上拉电阻 |
| 输出低电平 | 内部拉到 GND | 内部拉到 GND |
| 拉电流 | ✅可以向外输出电流 | ❌不能输出电流 |
| 灌电流 | ✅可以吸入电流 | ✅可以吸入电流 |
| 引脚可直接线与 | ❌不可以,多个推挽直接并联会损坏 IO | ✅支持线与逻辑 |
| 电平兼容 | 只能输出 MCU 本身 VDD 电平 | 可借助上拉电阻实现电平转换 |
10、I2C时序,I2C挂死原因与恢复处理方案
I2C 为开漏输出、线与总线;SDA、SCL 两根线,外部上拉电阻;标准 100kHz,快速 400kHz。
一、I2C 基础时序(主机视角)
| 信号 | 时序描述 |
|---|---|
| 起始信号 START | SCL 高电平期间,SDA 由高→低 |
| 停止信号 STOP | SCL 高电平期间,SDA 由低→高 |
| 数据位 | SCL 为低时 SDA 变化;SCL 高电平时 SDA 必须稳定,此时读取 bit |
| 应答 ACK | 发送完 8bit,主机释放 SDA,从机拉低 SDA 表示 ACK;SDA 保持高为 NACK 无应答 |
核心规则:SCL 高电平的时候 SDA 不允许随意变化,只有 START/STOP 允许 SDA 在 SCL 高时跳变。
I2C 一帧流程:
START → 7bit从机地址 + R/W位 → ACK → 字节数据 → ACK ... → STOP
二、I2C 挂死现象
现象:SCL 或者 SDA 被从机持续拉低,总线卡死,主机发送 START 无效,后续所有 I2C 通信全部失败。 示波器看:SDA 一直被拉低,SCL 为高,总线无法产生起始信号。
为什么会挂死(常见根因)
1、通信过程中主机异常复位(最常见) 主机发送过程中,发送到一半(比如发送完 8bit,等待 ACK 时刻)MCU 复位。 此时从机状态机还停留在接收数据阶段,从机认为:还需要接收时钟,于是持续把 SDA 拉低,等待主机提供 SCL 时钟。 主机复位后,I2C 外设寄存器全部重置,不知道从机还在等待时钟,直接发 START。
START 要求:SCL 为高,SDA 高→低;但是 SDA 被从机死死拉低,无法产生起始信号,总线锁死。
2、干扰,时序错乱,噪声毛刺导致从机状态机跑飞 电磁干扰,SDA/SCL 出现毛刺,从机 I2C 状态机进入未知状态,SDA 持续拉低。
3、从机断电、掉电,IO 处于拉低状态 从机芯片掉电,但总线还有上拉,从机 IO 内部 MOS 管导通,持续拉 SDA。
4、主机 I2C 外设 BUG,硬件外设卡死 硬件 I2C 外设寄存器状态异常,一直占用总线。
注意:软件 I2C(GPIO 模拟 I2C)、硬件 I2C 都可能挂死;硬件 I2C 更容易出现挂死。
三、I2C 总线恢复方案(硬件模拟 SCL 脉冲,"9 个时钟脉冲释放总线")
I2C 规范:只要给从机输出 9 个 SCL 时钟脉冲,如果 SDA 被拉低,在第 9 个时钟释放 SDA,从机就会退出当前传输,释放 SDA 总线。
操作前提:把 I2C 引脚切换为普通 GPIO 输出模式,脱离硬件 I2C 外设,用软件模拟 SCL 时钟。
恢复流程(标准软件恢复算法):
- 将 I2C_SCL、I2C_SDA 切换为普通 GPIO 开漏输出模式。
- 释放 SDA:SDA 置高(开漏,靠外部上拉拉高)。
- 判断 SDA 是否已经被释放;如果 SDA 已经是高,总线正常,直接退出恢复。
- 如果 SDA 仍然为低(被从机拉死):循环输出 9 次 SCL 时钟脉冲
- SCL 拉低 → 短暂延时 → SCL 拉高 →短暂延时
- 9 个时钟结束后,发送一次 I2C STOP 停止信号;
- 将 GPIO 切回 I2C 硬件外设复用模式,重新使用硬件 I2C。
cpp
// I2C总线解锁函数
void I2C_BusRecovery(void)
{
//1. 切换为普通GPIO开漏输出
I2C_SetGpioAsGpioMode();
I2C_SDA_SetHigh();
I2C_SCL_SetHigh();
HAL_Delay_us(5);
if(I2C_SDA_Read() == 1)
{
//总线已经空闲,直接返回
I2C_SetGpioAsI2cMode();
return;
}
//SDA被拉低,输出9个SCL时钟
for(uint8_t i = 0; i < 9; i++)
{
I2C_SCL_SetLow();
HAL_Delay_us(5);
I2C_SCL_SetHigh();
HAL_Delay_us(5);
}
//发送STOP信号:SCL高,SDA低变高
I2C_SDA_SetLow();
HAL_Delay_us(5);
I2C_SCL_SetHigh();
HAL_Delay_us(5);
I2C_SDA_SetHigh();
HAL_Delay_us(5);
//切回硬件I2C外设模式
I2C_SetGpioAsI2cMode();
}
⚠️关键点:必须先把引脚从硬件 I2C 复用切换为普通 GPIO,硬件外设不能接管引脚,否则软件控制不了 SCL/SDA 电平。
四、工程上完整容错策略
-
每次 I2C 通信出错(NACK、超时),调用总线恢复函数 I2C 读写增加超时判断,不要死等 ACK;一旦超时,执行总线恢复。
-
硬件 I2C 注意:出错之后,先关闭 I2C 外设,再做总线恢复,恢复完成再打开外设。
-
产品上电的时候,优先执行一次总线恢复。防止上电瞬间从机状态异常直接锁死。
-
硬件层面:SDA/SCL 上拉电阻合理取值 4.7k~10k;增加 RC 滤波,降低干扰。
五、硬件 I2C vs 软件模拟 I2C 优缺点
- 硬件 I2C:CPU 占用低;但是容易挂死,HAL 库硬件 I2C 坑较多,必须做总线恢复逻辑。
- 软件模拟 I2C (GPIO 模拟):时序全部自己控制,一旦通信异常,随时可以执行 9 时钟恢复,可控性强;缺点占用 CPU 时间。
11、SPI四种模式,SPI与I2C对比
SPI 是高速同步串行总线,4 线(SCLK、MOSI、MISO、NSS);全双工;没有应答机制;主机产生时钟。
SPI 模式由 CPOL (时钟极性)、CPHA (时钟相位) 两个参数决定,共 4 种模式。
CPOL 时钟极性
-
CPOL=0 :空闲时 SCLK 为低电平
-
CPOL=1 :空闲时 SCLK 为高电平
CPHA 时钟相位(数据采样沿)
-
CPHA=0 :第一个 SCLK 边沿采样数据,第二个边沿移位
-
CPHA=1 :第二个 SCLK 边沿采样数据,第一个边沿移位
重点:采样 = 读取数据;移位 = 更新输出数据。主机从机必须配置完全相同 CPOL/CPHA,否则通信乱码。
| SPI 模式 | CPOL | CPHA | 空闲电平 | 采样时刻 |
|---|---|---|---|---|
| Mode0 | 0 | 0 | SCLK 低 | 第一个上升沿采样 |
| Mode1 | 0 | 1 | SCLK 低 | 第二个下降沿采样 |
| Mode2 | 1 | 0 | SCLK 高 | 第一个下降沿采样 |
| Mode3 | 1 | 1 | SCLK 高 | 第二个上升沿采样 |
⚠️SPI 硬件要点:
- SPI 是全双工:发数据的同时必收到数据;即使你只想要发送,MISO 依然会读回无效字节。
- NSS 片选:低电平选中从设备;软件也可以用普通 GPIO 模拟 NSS。
- SPI没有应答 ACK,没有硬件校验,不知道从机是否收到数据;出错需要软件自己加校验(CRC)。
- 速率远高于 I2C,常见几 MHz~ 几十 MHz。
SPI vs I2C 完整对比
| 对比项 | SPI | I2C |
|---|---|---|
| 信号线 | 4 线:SCLK、MOSI、MISO、NSS(也可 3 线,去掉 NSS) | 2 线:SCL、SDA,外部需要上拉电阻 |
| 电气特性 | 推挽输出;不需要上拉电阻 | 开漏输出,必须外部上拉;支持线与 |
| 通信方式 | 全双工 | 半双工 |
| 设备寻址 | 靠 NSS 片选引脚;从机无设备地址 | 7bit/10bit 从机地址,总线挂载多设备只需要 2 根线 |
| 最高速度 | 几十 MHz,速度快 | 标准 100kHz,快速 400kHz,高速 1M~3.4M |
| 应答机制 | 无硬件 ACK,不知道从机是否接收成功 | 有 ACK/NACK 应答,可判断从机是否存在 |
| 总线挂死风险 | 几乎不会总线挂死 | 容易挂死,需要 9 时钟总线恢复机制 |
| 多设备挂载 | 每个从机需要独立 NSS 引脚;引脚消耗多 | 直接挂总线,仅 2 根线,引脚占用少 |
| 噪声容错 | 相对较好 | 容易受干扰,毛刺会导致状态机跑飞挂死 |
| 典型器件 | W25Q Flash、LCD、ADC、OLED | EEPROM、RTC、传感器、IO 扩展芯片 |
| 缺点 | 占用引脚多;无应答;没有硬件校验 | 速率低;容易总线挂死;半双工 |
12、DMA原理,DMA乒乓缓冲使用场景,DMA会带来哪些问题
一、DMA 基本原理
DMA:直接存储器访问(Direct Memory Access) 作用:数据搬运不占用 CPU,硬件外设直接完成数据搬运,搬运完成后通知 CPU(中断)。 没有 DMA:CPU 需要介入,循环读外设寄存器、写内存,占用 CPU 算力。
DMA 搬运的三类方向(STM32):
- 外设 → 内存:串口接收、SPI 接收、ADC 采样(外设寄存器 → RAM)
- 内存 → 外设:串口发送、SPI 发送(RAM → 外设 DR 寄存器)
- 内存 → 内存:存储器到存储器拷贝,只有 DMA1 不支持,DMA2 支持。
工作流程:
- CPU 配置 DMA:源地址、目的地址、传输数据长度、传输方向、开启中断(传输完成 / 半传输)。
- 启动 DMA,CPU 释放,去做别的任务。
- 硬件 DMA 控制器接管总线,完成数据搬运。
- 传输结束,触发 DMA 中断,CPU 进来处理后续业务。
DMA 访问内存时会占用总线矩阵,CPU 和 DMA 会竞争总线。
关键寄存器:
CNDTR:剩余待传输数据个数;递减到 0 代表传输完成。
DMA 两种工作模式:
- 普通模式 (Normal):传输完 CNDTR 个数据,DMA 停止,需要软件重新配置启动。
- 循环模式 (Circular):CNDTR 计数到 0,自动重装初始值,持续循环搬运,不停。
循环模式常配合乒乓缓冲使用。
二、DMA 乒乓缓冲(Double‑Buffer,双缓冲)
使用场景:持续不断接收数据流(串口、SPI、ADC 连续采样)
需求:外设源源不断收到数据,不能停止接收,同时 CPU 要处理已经收到的数据 。 如果只用 1 块缓冲区:DMA 正在往缓冲区写数据的时候,CPU 同时读取这块缓冲区,会出现数据撕裂(一部分旧数据,一部分新数据)。
乒乓缓冲:开辟两块独立缓冲区 BufferA、BufferB。
DMA 循环模式 + 双缓冲机制(部分 MCU 硬件支持乒乓;没有硬件双缓冲就软件模拟乒乓逻辑)
工作流程(硬件 DMA 乒乓)
- DMA 先往 BufferA 写入数据;BufferB 空闲,CPU 处理 BufferB 的数据。
- BufferA 写满,DMA 自动切换目标地址到 BufferB,触发半 / 满完成中断。
- CPU 在中断中处理刚刚填满的 BufferA;此时 DMA 正在写 BufferB。
- BufferB 写满,DMA 切回 BufferA;CPU 处理 BufferB。
DMA 永远往一块缓冲区存新数据,CPU 处理另外一块已经写好的缓冲区,读写分离,互不冲突。
✅适用场景:
- UART DMA 持续接收不定长 / 连续数据流
- SPI DMA 持续采样
- ADC 连续采样数据流
- 音频数据流采集输出
❌不适合:少量单次传输。
软件模拟乒乓:没有硬件双缓冲时,用 Circular 循环 DMA,通过半传输中断、传输完成中断,手动切换处理 A/B 缓冲区。
⚠️关键点:
- DMA 操作的内存,必须满足内存对齐! DMA 硬件不支持非对齐访问,非对齐会数据错乱。
- 缓冲区不能放在栈上!栈内存是局部变量,函数退出销毁;DMA 要用全局 / 静态数组,放在 BSS/DATA 段。
三、DMA 带来的问题
1. 内存对齐问题(高频)
DMA 硬件控制器不支持非对齐访问。 如果 DMA 源 / 目的地址不是数据宽度整数倍,传输的数据全部错乱。
注意:#pragma pack(1)压缩的结构体,指针绝对不能交给 DMA。
2. 缓冲区覆盖、数据撕裂
单缓冲区循环 DMA:DMA 正在写缓冲区,CPU 同时读取缓冲区,读到一半旧一半新的残缺数据。 👉解决:使用乒乓双缓冲;或者禁止 CPU 访问正在被 DMA 操作的那块 buffer。
3. DMA 与 CPU 总线竞争(总线矩阵冲突)
DMA 和 CPU 同时访问 SRAM,总线仲裁。 现象:CPU 代码执行速度被拖慢;极端情况,CPU 访问 RAM 会有等待周期。 高频率 DMA 大数据量传输会抢占总线。
4. 缓冲区位于栈上,内存非法访问 HardFault
cpp
void uart_dma(void)
{
uint8_t buf[256]; //局部变量,栈
HAL_UART_Receive_DMA(&huart1, buf, 256);
}
函数执行完毕,栈内存回收;DMA 还在往已经释放的栈地址写数据,内存踩踏,随机死机。 ✅必须用static全局数组。
5. DMA 中断与业务逻辑同步问题
DMA 传输完成中断只是告诉 "搬运完成",不代表外设全部处理完毕。 例如 UART DMA:DMA 搬完字节,但是 UART 的移位寄存器中可能还有 1~2 字节还没发送出去,如果立刻关闭 DMA / 修改缓冲区,会丢字节。
解决:要等待外设的 TC 发送完成标志位,而不是只看 DMA 完成标志。
6. 多 DMA、多外设冲突
多个 DMA 流同时工作,总线压力大;DMA 流配置错误,源目的地址写反。
7. 变量被 DMA 后台修改,编译器优化问题
DMA 后台修改内存缓冲区,CPU 读取缓冲区,编译器有可能把缓冲区数据缓存到寄存器,读不到 DMA 更新的最新数据。
解决方案:DMA 接收的缓冲区数组,加上volatile修饰,告诉编译器不要优化缓存。
cpp
volatile uint8_t dma_buf[256];
⚠️volatile 不能解决撕裂,只是解决编译器优化读取旧值。
8. 循环 DMA 忘记关闭,内存越界
循环模式 DMA 一旦开启,会不停往内存写,不停止;如果逻辑出错,会持续写超出缓冲区,踩踏相邻内存。
9. HAL 库坑:DMA 没有关闭就重新配置,状态机卡死
HAL 库的 DMA / 外设句柄状态没有复位,重复调用HAL_UART_Receive_DMA(),导致 DMA 不工作。要先停止 DMA,再重新启动。
13、Cache基本概念,DMA场景下Cache一致性会带来哪些问题
注意:Cortex‑M4/M7/M33 带 Cache;Cortex‑M0/M0+/M3 没有 Cache。M7 有 I‑Cache 指令缓存、D‑Cache 数据缓存。 很多人在 M7 上跑 DMA 踩坑,就是 D‑Cache 一致性问题。
一、Cache 基础概念
CPU 访问 RAM 速度慢,Cache 是 CPU 内部高速小容量存储器,速度远高于 SRAM。分为:
- I‑Cache(指令 Cache):缓存程序代码,一般很少出问题。
- D‑Cache(Data Cache,数据缓存):缓存读写的数据,DMA 坑全部来自 D‑Cache。
工作原理
CPU 读写内存,不是直接访问 SRAM:
- CPU 读数据:优先看 D‑Cache 有没有这份数据。
- 命中:直接读 Cache,不访问外部 SRAM,速度快。
- 未命中:去 SRAM 把数据读到 Cache,再给 CPU。
- CPU 写数据:
- 写回模式 (Write‑back) :CPU 先写到 D‑Cache,不立刻写到 SRAM;Cache 行满了才刷回 SRAM。(M7 默认)
- 直写模式 (Write‑through):写 Cache 同时同步写 SRAM,开销大。
Cache 行(Cache line):Cache 以行为单位管理,M7 Cache 行大小 32 字节。刷 Cache、无效化都必须按 Cache 行操作。
两个关键操作:
- Clean(刷回) :把 D‑Cache 里修改过的脏数据写回 SRAM,Cache 保留副本。
- Invalidate(无效化):把 D‑Cache 对应行作废;CPU 下次读,强制从 SRAM 重新读取最新数据。
二、DMA 与 Cache 矛盾根源
DMA 是硬件直接访问 SRAM,DMA 根本看不见 CPU 内部 D‑Cache。
CPU 读写走 D‑Cache;DMA 直接访问 SRAM。 Cache 中的数据 和 SRAM 物理内存的数据,可能不一致,就是 Cache 一致性问题。
两种典型场景:
场景 1:CPU 写缓冲区,DMA 读取该缓冲区(内存→外设,DMA 发送)
流程:
- CPU 填充发送缓冲区 buf,数据写到D‑Cache,还没有刷到 SRAM(Write‑back 写回模式)。
- DMA 启动,直接去 SRAM 搬运数据。 👉 SRAM 里还是旧数据!DMA 发出错误旧数据。
✅解决:DMA 发送之前,Clean(刷 D‑Cache),把 Cache 脏数据强制写回 SRAM。
场景 2:DMA 往缓冲区写数据(外设→内存,DMA 接收)
流程:
- DMA 把收到的数据写入SRAM。
- CPU 读取 buf,优先读 D‑Cache 里面旧的缓存副本,不去读 SRAM 的新数据。 👉 CPU 读到旧数据,看不到 DMA 刚刚收到的新内容。
✅解决:DMA 接收完成后,对该内存区域做 Invalidate(无效 D‑Cache),CPU 重新从 SRAM 读取。
⚠️绝对不能搞反操作!
- CPU 写,DMA 读:需要 Clean
- DMA 写,CPU 读:需要 Invalidate
三、实际工程现象(M7 高频 bug)
1、DMA 发送:发送出来数据乱码,CPU 看缓冲区是正确的,DMA 发出去是旧值。
CPU 改了数组,存在 Cache,SRAM 没更新,DMA 读 SRAM 拿旧数据。/2DMA 接收:
2、DMA 接收中断进来,缓冲区看数据全是旧的,重启 DMA 偶尔正常。
DMA 已经把新数据写到 SRAM,但 D‑Cache 还保存旧副本,CPU 读 Cache 旧值。
3、偶现 bug:有时候正常有时候异常。
刚好该内存没有命中 Cache,就正常;命中 Cache 就出错。复现很难。
四、额外约束
- Cache 操作是以**Cache 行(32 字节)**为单位,缓冲区起始地址、大小最好按 Cache 行对齐。 如果缓冲区跨两个 Cache 行,Clean/Invalidate 会影响相邻内存,造成数据破坏。
缓冲区要做对齐:
__attribute__((aligned(32)))
- 不要把 DMA 缓冲区放在 Cache 禁止区域(如果开启 MPU),也可以 MPU 配置该内存区域关闭 D‑Cache,绕开一致性问题。
MPU 设置内存属性为 Non‑cacheable,CPU 访问直接走 SRAM,不需要 Clean/Invalidate,代价是 CPU 访问速度下降。产品常用方案。
- 乒乓缓冲同样要处理 Cache 一致性,两块 buffer 都要对齐,分别做 Clean/Invalidate。
五、伪代码示例(Cortex‑M7)
cpp
// 接收:DMA往内存写,读完之后Invalidate
DMA_StartRecv(buf, len);
//等待DMA传输完成
SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);
//现在CPU读取buf才是DMA收到的真实数据
//发送:CPU写好buffer,DMA读内存;发送前Clean
FillSendBuf(buf);
SCB_CleanDCache_by_Addr((uint32_t *)buf, len);
DMA_StartSend(buf, len);
注意:Cortex‑M0/M0+/M3/M4 没有 D‑Cache,完全没有这个问题,很多人从 F1/F4 跳到 M7 就踩坑。
14、CAN标准帧结构,CAN对比SPI/I2C优势
CAN 总线:Controller Area Network,CAN2.0A 标准帧 (11 位 ID)、CAN2.0B 扩展帧 (29 位 ID);差分信号,抗干扰强,工业汽车总线。
一、CAN2.0A 标准帧(数据帧)完整结构
标准帧一共 7 段,从 SOF 到 EOF:
| 字段 | 长度 | 说明 |
|---|---|---|
| SOF 帧起始 | 1bit | 显性 0,标志帧开始 |
| 仲裁场 | 12bit | 11 位 ID + RTR 位ID:报文标识符;RTR=0 数据帧,RTR=1 远程帧 |
| 控制场 | 6bit | IDE、R0、DLC0‑3IDE=0 代表标准帧;DLC:数据长度码 0~8 字节 |
| 数据场 | 0‑64bit | 0~8 字节有效数据,DLC 决定长度 |
| CRC 场 | 16bit | CRC15 校验 + CRC 界定符 1bit |
| ACK 应答场 | 2bit | ACK 槽 + ACK 界定符接收正确的节点把 ACK 槽拉为显性;发送方检测 ACK,判断是否有节点接收 |
| EOF 帧结束 | 7bit | 7 个隐性 1,帧结束标记 |
关键概念:
- 显性位 (0)、隐性位 (1) :总线差分,多个节点同时发送,显性优先覆盖隐性,实现非破坏性位仲裁。ID 越小优先级越高。
- 远程帧:没有数据段,RTR=1,用来请求其他节点发送对应 ID 报文。
- CAN 最大有效负载 8 字节(传统 CAN2.0);CAN FD 支持最大 64 字节。
- 波特率:125k/250k/500k/1M,总线最长距离和波特率负相关。
总线仲裁机制
多个节点同时发报文,逐 bit 对比 ID;发送隐性但是监测到总线为显性,节点立刻停止发送,退出竞争。ID 数值越小优先级越高,不浪费时间,非破坏性仲裁。
二、CAN 物理层要点
- 差分总线:CAN_H、CAN_L;差分电压来表示 0/1,抗干扰能力极强。
- 总线两端必须接 120Ω 终端电阻,阻抗匹配,抑制信号反射。
- 多节点挂载,最多 110 个节点。
- 总线短路、单线损坏,部分场景还可以降级通信。
三、CAN 对比 I2C / SPI
| 对比项 | CAN | I2C | SPI |
|---|---|---|---|
| 物理信号 | 差分信号 CAN_H/CAN_L | 单端开漏,SDA/SCL | 单端推挽 SCLK/MOSI/MISO/NSS |
| 传输距离 | 远,1M 速率下 40m;125k 可达 500m | 板内短距离,米级以内 | 板内极短距离 |
| 多节点 | 支持多节点,总线型;硬件仲裁 | 多设备总线挂载,靠地址;无硬件仲裁 | 每个从机独立 NSS,点对点 / 少量节点 |
| 错误处理 | 硬件 CRC、应答、错误计数、自动重发;检测总线错误 | 软件 ACK;无硬件错误统计;无自动重发 | 无硬件校验,无应答,全靠软件 |
| 总线仲裁 | ✅硬件非破坏性位仲裁,ID 优先级 | ❌没有硬件仲裁 | ❌无仲裁 |
| 抗干扰能力 | 极强(差分) | 一般,容易受干扰,容易挂死 | 较好,单端 |
| 负载长度 | CAN2.0 最大 8 字节;CAN‑FD 64 字节 | 单字节应答,无长度限制 | 无硬件限制 |
| 典型速率 | 最高 1M (CAN2.0) | 400K | 几十 MHz |
| 故障容错 | 节点故障可自动离线,不拖垮整条总线 | 一个从机异常,总线挂死 | 一个从机异常不影响总线 |
| 应用场景 | 汽车、工业、机载设备,板间通信 | 板内传感器 EEPROM | RTC Flash,板内高速外设 |
四、CAN 相比 I2C、SPI 核心优势
- 差分信号,抗干扰能力强,适合板间、设备之间远距离通信,I2C/SPI 大多只用于 PCB 板内。
- 硬件自带完整错误检测机制:CRC 校验、ACK 应答、错误计数器;检测到错误自动重发。I2C 只有简单 ACK,SPI 完全没有硬件校验。
- 硬件仲裁机制,多个节点同时发送不会冲突,ID 决定报文优先级,适合多节点分布式系统。I2C、SPI 没有硬件仲裁。
- 故障隔离:节点发生严重错误,会自动进入总线离线,不会把整个总线搞瘫痪。I2C 从机异常直接锁死整条总线。
- 支持多节点总线组网,不需要像 SPI 那样每个设备分配独立片选引脚。
五、CAN 劣势
- 硬件成本高,需要 CAN 控制器 + CAN 收发器;I2C/SPIMCU 内部自带外设,只需要简单外围。
- CAN2.0 一帧有效数据最多 8 字节,大数据传输效率低;需要分包。
- 协议复杂,寄存器配置、错误处理、滤波需要软件处理。
15、CAN-FD与传统CAN差异,BRS位作用
CAN‑FD:Flexible Data‑rate,灵活数据速率 CAN;物理层和传统 CAN 收发器兼容,差分 CAN_H/CAN_L,终端电阻依旧 120Ω。
一、核心对比
| 项目 | 传统 CAN2.0 | CAN‑FD |
|---|---|---|
| 最大数据长度 | 8 字节 | 64 字节,DLC=15 对应 64 字节 |
| 波特率 | 整帧同一个速率,最高 1Mbps | 双速率:仲裁段低速 (NBR);数据段可高速 (DBR),最高 8Mbps |
| 控制场新增位 | 保留位 r0 | FDF、BRS、ESI三个新增 bit |
| CRC 校验 | 固定 15bit CRC | ≤16 字节用 17bit;>16 字节用 21bit CRC,校验能力更强CAN in Aut... |
| 远程帧 | 支持 RTR 远程请求帧 | 取消远程帧,改为 RRS 保留位 |
| 兼容性 | 只能接收 CAN2.0 帧 | 可同时接收 CAN2.0 与 CAN‑FD 帧;老 CAN 控制器收到 FDF=1 会报格式错 |
三个关键新增位
- FDF(FD 格式位) FDF=0:传统 CAN 帧;FDF=1:CAN‑FD 帧。用来区分是普通 CAN 还是 CAN‑FD 报文。
- BRS(Bit‑Rate Switch,比特率切换位,重点)
- ESI(Error State Indicator 错误状态指示):发送节点错误被动时置显性 0,通知其他节点本节点错误状态CAN in Aut...。
为什么仲裁段不能提速:仲裁阶段所有节点同时采样 ID 做总线仲裁,全网必须严格同步;速率太高,晶振偏差、传播时延会导致仲裁逻辑错乱。赢得总线之后,只有一个发送节点,数据段才可以提速。
二、BRS 位作用
BRS 位于 CAN‑FD 帧的控制场,只有 FDF=1(CAN‑FD 帧)时 BRS 才生效。
- BRS = 1(隐性):开启速率切换 在采样完 BRS 这个 bit 之后,总线立刻切换到数据段高速波特率 传输:数据场、CRC 场使用高速;到 CRC 界定符位置,切回仲裁段低速,后续 ACK、EOF 依旧使用仲裁速率。
- BRS = 0(显性):不切换速率 整帧全部使用仲裁段低速,和传统 CAN 速率一致,可作为降级兼容模式。
- 整帧全部使用仲裁段低速,和传统 CAN 速率一致,可作为降级兼容模式。
报文速率分段: SOF→仲裁场→控制场 (BRS):仲裁速率 NBR(500k/1M) BRS 采样后 → Data → CRC:如果 BRS=1,切换为数据高速 DBR(2M/4M/8M) CRC 界定符之后切回仲裁速率,ACK、EOF 使用低速,保证网络内普通 CAN 节点可以识别帧结束。
⚠️关键点:
- BRS 只是帧内的通知标记,硬件控制器必须预先配置两套波特率:仲裁时序、数据段时序;BRS=1 只是触发控制器切换到另一套时序寄存器Microchip。
- 总线所有 CAN‑FD 节点必须配置相同的两套波特率,否则通信乱码。
- BRS=1 只是 CAN‑FD 帧才有效;传统 CAN 帧不存在 BRS。
三、CAN‑FD 带来的优势
- 单帧最大 64 字节,减少分包,降低协议开销,提升总线有效吞吐量。传统 CAN 传输 64 字节需要 8 帧,CAN‑FD 只需要 1 帧。
- BRS 实现帧内变速:仲裁段保证总线仲裁可靠性;数据段高速传输,兼顾可靠性与速度。
- CRC 升级,长报文错误检测能力提升。
- 物理层兼容原有 CAN 收发器,不需要更换硬件。
四、工程踩坑点
- ISO‑CAN‑FD vs Non‑ISO(经典坑) 早期 Non‑ISO CAN‑FD CRC 填充规则和标准 ISO11898‑1 不一致,新旧控制器直接对接会报 CRC 错误,硬件必须选择 ISO 模式。
- 网络中混有传统 CAN2.0 节点: 传统 CAN 节点识别不了 CAN‑FD 帧(FDF=1),会报格式错误,持续报错。混合网络不能发 CAN‑FD 报文,只能用 CAN2.0A/B。
- DLC 大于 8 时,CAN‑FD DLC 依旧是 4bit (0‑15),DLC=9~15 映射到 12/16/20/24/32/48/64 字节,不是 DLC 等于实际字节数,这点很容易写错。
- 虽然收发器兼容,但高速数据段(4M/8M)对布线、终端电阻、信号完整性要求更高。
16、中断上下文注意事项,中断里面哪些操作禁止执行
中断上下文:进入中断服务函数 ISR,此时不在任务 / 主循环上下文;抢占普通任务,优先级高。 RTOS 环境(FreeRTOS):区分中断上下文 和任务上下文;中断里不能调用任务层 API。
一、中断上下文核心特点
- 中断会抢占主函数、普通任务,执行时间越短越好,禁止长耗时操作。
- 栈使用:Cortex‑M 默认使用 MSP 主栈,不是任务栈。
- 中断嵌套:高优先级中断可以抢占低优先级中断;同优先级一般不能嵌套。
- 中断内访问全局共享变量,和主循环存在并发访问,会产生数据撕裂。
二、中断服务函数里面禁止做什么(重点)
1. ❌禁止调用 malloc / free
malloc/free会操作堆管理器,内部有互斥保护。
- 如果主循环正在 malloc,中断进来又调用 malloc,堆管理状态被破坏,堆损坏,随机死机。
内存申请释放放到主循环 / 任务中处理。缓冲区尽量全局静态提前分配好。
2. ❌禁止调用会阻塞、等待延时的函数
1)HAL_Delay() / delay_ms()阻塞延时; 中断内系统滴答定时器可能被本中断屏蔽,延时不准,并且长时间占用中断,其他中断得不到响应。
如果一定要延时,只能用寄存器级微秒忙等待(短时间),严禁毫秒级阻塞。
2)阻塞式外设读写:I2C 阻塞读写、SPI 阻塞读写、等待应答循环死等。 一旦外设无应答,中断卡死,整个系统瘫痪。
外设操作优先使用 DMA、非阻塞,中断只做标记、拷贝数据、置标志位。
3. ❌RTOS 中,中断上下文禁止调用任务 API(会阻塞的 API)
FreeRTOS 举例:
- 禁止:
vTaskDelay()、xTaskNotifyWait()、xQueueReceive()(阻塞版本)
这些 API 会触发任务切换,中断上下文不允许任务挂起 / 阻塞。
✅中断允许调用不带阻塞的中断专用 API : xQueueSendFromISR()、xTaskNotifyFromISR(),结尾带FromISR;并且传入pxHigherPriorityTaskWoken参数,最后调用portYIELD_FROM_ISR()。
不带 FromISR 的普通 API,绝对不能在中断中调用,会系统崩溃。
4. ❌禁止大循环、复杂业务逻辑、大量数据处理
中断只做:硬件相关的紧急处理,标记、拷贝少量数据,通知任务。 复杂解析、协议处理、数据运算全部扔到主循环 / RTOS 任务。
中断执行时间过长,会屏蔽其他中断,丢串口、CAN、ADC 等外设中断,造成丢数据。
5. ❌不要在中断和主循环之间共享变量不加保护
全局变量同时被 ISR + 主循环访问,会产生数据撕裂。 处理手段:
- 单字节简单标志:加
volatile; - 多字节变量(uint32_t、数组、结构体):访问共享变量时主循环关闭中断,访问完再开中断; RTOS 环境可以用队列、信号量做数据传递,避免直接操作全局共享缓冲区。
cpp
//错误,无保护,存在撕裂
uint32_t g_cnt;
//主循环
g_cnt +=1;
//中断
g_cnt +=1;
6.❌中断服务函数内不要使用 printf 打印
printf内部很重,会占用大量时间;底层串口发送如果阻塞,中断会长时间卡死。 调试打印放到任务 / 主循环;中断只置标志,任务再打印。
7. ❌不要在中断里面操作 Flash 读写(片内 Flash 擦写)
Flash 擦写耗时很长,毫秒级;并且擦写期间 CPU 可能无法取指,中断内执行会严重影响系统。
8. ❌禁止在中断中调用会触发新中断的操作
部分外设操作会再次触发本中断,导致中断风暴,CPU 永远跑在中断。
9. ❌中断函数内定义超大局部数组
中断使用 MSP 主栈,局部大数组容易栈溢出 HardFault。大数组用全局 static。
三、中断里面推荐做什么
- 读取外设寄存器,拷贝少量数据;
- 更新简单状态标志;
- RTOS:调用
xxxFromISR版本 API,向任务发送队列、事件通知,把业务交给任务; - 清除中断标志位(注意:有些标志写 1 清零,不要重复清)。
核心原则:中断快进快出,只处理硬件紧急事情,复杂业务交给任务 / 主循环。
四、常见踩坑现象
- 中断里面 HAL_Delay,系统卡死,其他中断不响应;
- 中断中调用普通
xQueueSend(),直接 HardFault; - 主循环与中断共用全局结构体,数据随机错乱(数据撕裂);
- 中断中 malloc,偶现堆损坏死机。
17、手撕代码,求两个链表交点
链表相交:两个链表存在公共节点,节点地址相同,不是值相等。 相交之后后面所有节点全部重合,Y 字形;不会出现 X 交叉。
cpp
typedef struct ListNode {
int val;
struct ListNode *next;
} ListNode;
思路 1:双指针法,最优,O (m+n) 时间 O (1) 空间(面试手撕首选)
算法逻辑:
- 指针 pA 指向 A 头,pB 指向 B 头;
- pA、pB 同时向后走;
- pA 走到末尾 NULL,则跳到 B 链表头;pB 走到末尾 NULL,则跳到 A 链表头;
- 循环直到
pA == pB,返回该节点(相交点 / NULL 不相交)。
原理:
- 设 A 长度 a,B 长度 b;公共部分长度 c。
- pA 走过:
a + (b‑c) - pB 走过:
b + (a‑c)两者路程相等,会在交点相遇;无交点则同时走到 NULL 退出。
cpp
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB)
{
if(headA == NULL || headB == NULL)
return NULL;
ListNode *pA = headA;
ListNode *pB = headB;
// 相等即相遇,相交点 或者 同时为NULL
while(pA != pB)
{
pA = (pA == NULL) ? headB : pA->next;
pB = (pB == NULL) ? headA : pB->next;
}
return pA;
}
思路 2:先求长度,快慢对齐(容易理解,也常考)
- 分别求链表 A、B 长度 lenA,lenB
- 长链表指针先走差值步,使得两个指针到末尾距离相等
- 两个指针一起向后遍历,第一个相等地址就是交点
cpp
//求链表长度
int getLen(ListNode *head)
{
int len = 0;
ListNode *p = head;
while(p != NULL)
{
len++;
p = p->next;
}
return len;
}
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB)
{
int lenA = getLen(headA);
int lenB = getLen(headB);
ListNode *pA = headA;
ListNode *pB = headB;
//长链表先走
while(lenA > lenB)
{
pA = pA->next;
lenA--;
}
while(lenB > lenA)
{
pB = pB->next;
lenB--;
}
//同步走
while(pA != NULL && pB != NULL)
{
if(pA == pB)
return pA;
pA = pA->next;
pB = pB->next;
}
return NULL;
}