【嵌入式面试题一】

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、数组名什么时候退化为指针

数组名只有在下面场景,隐式转换成首元素指针

  1. 数组名赋值给指针变量 p = arr;
  2. 函数传参 func(arr);
  3. arr + 1*arrarr[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 支持非对齐访问,但会性能损耗。

三、不对齐会发生什么现象

分两种内核情况:

  1. Cortex‑M0/M0+(无硬件非对齐支持)

结构体强制压缩 #pragma pack(1),然后直接指针强转访问结构体成员。

👉 直接 HardFault,程序跑飞死机。

  1. 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) 结构体注意事项

  1. 成员访问会非对齐;M0/M0 + 访问成员会 HardFault。
  2. packed 结构体的指针严禁交给 DMA,DMA 硬件不支持非对齐。
  3. 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);
  1. 把这块堆内存标记为空闲,还给堆管理器,可以被后续 malloc 重新分配使用
  2. 不会把 p 指针变量本身置 NULL!
  3. 不会把内存里面的数据清零,原来的数据还残留在内存。

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. 上电复位硬件阶段

  1. 电源稳定,MCU 产生复位信号。
  2. CPU 从0x00000000(向量表基地址) 读取前两个 32bit 值:
    • 0x00000000MSP 初始栈顶地址 ,设置主栈指针SP = MSP
    • 0x00000004:复位中断入口地址,PC 跳转到Reset_Handler,进入启动文件汇编。

Cortex‑M 向量表第一个不是指令,是栈指针!这和 ARM‑A 系列不一样。

2. 启动文件 (.s) 作用(汇编文件,如startup_stm32f407xx.s

启动文件是复位后执行的第一段程序,汇编编写。

Reset_Handler内部关键步骤:

  1. 从链接脚本符号获取 栈 (__initial_sp)、堆 (__heap_base、__heap_limit) 的地址。
  2. .data段拷贝 :把 Flash 里的初始化数据拷贝到 RAM。 .data:已经初始化的全局变量 int g_a=10;,存储在 Flash,运行时要复制到 RAM。
  3. .bss段清零 :把 RAM 中 bss 区域全部置 0。 .bss:未初始化全局 / 静态变量 int g_b;,运行时 RAM 必须初始化为 0。
  4. 调用SystemInit():配置系统时钟、PLL,设置系统主频(STM32)。
  5. 调用库函数入口__main(Keil),不是用户 main。
  6. __main做完 C 库初始化、全局对象初始化,最后调用用户的main()

启动文件还定义:整个中断向量表 ,所有中断的入口函数,弱定义WEAK中断处理函数。

cpp 复制代码
void HardFault_Handler(void) __attribute__ ((weak));

用户不重写,就执行默认死循环;用户自己实现同名函数,覆盖弱定义。

启动文件不做的事:不决定内存放在哪里,地址由链接脚本决定

启动文件核心职责总结

  1. 设置 MSP 栈指针
  2. data 段拷贝、bss 段清零(C 语言运行环境准备)
  3. 中断向量表定义
  4. 调用 SystemInit、跳转到 main 入口

3. 链接脚本作用

Keil MDK:分散加载脚本 .scat;GCC:.ld链接脚本

编译器只关心代码语法;链接脚本决定:代码 / 数据放在 Flash 哪块、RAM 哪块,定义各个段的起始地址、大小

主要划分两大存储区域:

  1. FLASH(ROM) :存放 .text代码段、.rodata常量字符串;还有.data段的初始化副本。
  2. 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。

链接脚本核心职责

  1. 定义 Flash、RAM 的起始地址与大小(MCU 芯片手册的存储映射)。
  2. 把各个.o目标文件的各个段 (.text/.rodata/.data/.bss) 分配到 ROM/RAM。
  3. 导出全局符号,供启动文件汇编使用。
  4. 决定栈、堆在 RAM 中的位置与大小。
  5. 决定向量表存放的 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. 完整流程梳理

  1. 上电复位 ,硬件读取向量表前两个字,初始化 MSP 栈指针,PC 进入Reset_Handler(启动文件)。
  2. 启动文件根据链接脚本导出符号:
    • 将 Flash 中.data段初始化数据拷贝到 RAM 的.data区域。
    • 将 RAM 中.bss段全部清零。
  3. 调用SystemInit(),配置 RCC/PLL,系统时钟初始化。
  4. 调用__main:C 运行库初始化,处理全局变量初始化。
  5. 跳转用户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 时钟。

恢复流程(标准软件恢复算法):

  1. 将 I2C_SCL、I2C_SDA 切换为普通 GPIO 开漏输出模式。
  2. 释放 SDA:SDA 置高(开漏,靠外部上拉拉高)。
  3. 判断 SDA 是否已经被释放;如果 SDA 已经是高,总线正常,直接退出恢复。
  4. 如果 SDA 仍然为低(被从机拉死):循环输出 9 次 SCL 时钟脉冲
    • SCL 拉低 → 短暂延时 → SCL 拉高 →短暂延时
  5. 9 个时钟结束后,发送一次 I2C STOP 停止信号;
  6. 将 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 电平。

四、工程上完整容错策略

  1. 每次 I2C 通信出错(NACK、超时),调用总线恢复函数 I2C 读写增加超时判断,不要死等 ACK;一旦超时,执行总线恢复。

  2. 硬件 I2C 注意:出错之后,先关闭 I2C 外设,再做总线恢复,恢复完成再打开外设

  3. 产品上电的时候,优先执行一次总线恢复。防止上电瞬间从机状态异常直接锁死。

  4. 硬件层面: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 硬件要点:

  1. SPI 是全双工:发数据的同时必收到数据;即使你只想要发送,MISO 依然会读回无效字节。
  2. NSS 片选:低电平选中从设备;软件也可以用普通 GPIO 模拟 NSS。
  3. SPI没有应答 ACK,没有硬件校验,不知道从机是否收到数据;出错需要软件自己加校验(CRC)。
  4. 速率远高于 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):

  1. 外设 → 内存:串口接收、SPI 接收、ADC 采样(外设寄存器 → RAM)
  2. 内存 → 外设:串口发送、SPI 发送(RAM → 外设 DR 寄存器)
  3. 内存 → 内存:存储器到存储器拷贝,只有 DMA1 不支持,DMA2 支持。

工作流程:

  1. CPU 配置 DMA:源地址、目的地址、传输数据长度、传输方向、开启中断(传输完成 / 半传输)。
  2. 启动 DMA,CPU 释放,去做别的任务。
  3. 硬件 DMA 控制器接管总线,完成数据搬运。
  4. 传输结束,触发 DMA 中断,CPU 进来处理后续业务。

DMA 访问内存时会占用总线矩阵,CPU 和 DMA 会竞争总线。

关键寄存器:

  • CNDTR:剩余待传输数据个数;递减到 0 代表传输完成。

DMA 两种工作模式:

  1. 普通模式 (Normal):传输完 CNDTR 个数据,DMA 停止,需要软件重新配置启动。
  2. 循环模式 (Circular):CNDTR 计数到 0,自动重装初始值,持续循环搬运,不停。

循环模式常配合乒乓缓冲使用。

二、DMA 乒乓缓冲(Double‑Buffer,双缓冲)

使用场景:持续不断接收数据流(串口、SPI、ADC 连续采样)

需求:外设源源不断收到数据,不能停止接收,同时 CPU 要处理已经收到的数据 。 如果只用 1 块缓冲区:DMA 正在往缓冲区写数据的时候,CPU 同时读取这块缓冲区,会出现数据撕裂(一部分旧数据,一部分新数据)

乒乓缓冲:开辟两块独立缓冲区 BufferA、BufferB。

DMA 循环模式 + 双缓冲机制(部分 MCU 硬件支持乒乓;没有硬件双缓冲就软件模拟乒乓逻辑)

工作流程(硬件 DMA 乒乓)

  1. DMA 先往 BufferA 写入数据;BufferB 空闲,CPU 处理 BufferB 的数据。
  2. BufferA 写满,DMA 自动切换目标地址到 BufferB,触发半 / 满完成中断。
  3. CPU 在中断中处理刚刚填满的 BufferA;此时 DMA 正在写 BufferB。
  4. BufferB 写满,DMA 切回 BufferA;CPU 处理 BufferB。

DMA 永远往一块缓冲区存新数据,CPU 处理另外一块已经写好的缓冲区,读写分离,互不冲突。

✅适用场景:

  1. UART DMA 持续接收不定长 / 连续数据流
  2. SPI DMA 持续采样
  3. ADC 连续采样数据流
  4. 音频数据流采集输出

❌不适合:少量单次传输。

软件模拟乒乓:没有硬件双缓冲时,用 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。分为:

  1. I‑Cache(指令 Cache):缓存程序代码,一般很少出问题。
  2. D‑Cache(Data Cache,数据缓存):缓存读写的数据,DMA 坑全部来自 D‑Cache。

工作原理

CPU 读写内存,不是直接访问 SRAM:

  1. CPU 读数据:优先看 D‑Cache 有没有这份数据。
    • 命中:直接读 Cache,不访问外部 SRAM,速度快。
    • 未命中:去 SRAM 把数据读到 Cache,再给 CPU。
  2. CPU 写数据:
    • 写回模式 (Write‑back) :CPU 先写到 D‑Cache,不立刻写到 SRAM;Cache 行满了才刷回 SRAM。(M7 默认)
    • 直写模式 (Write‑through):写 Cache 同时同步写 SRAM,开销大。

Cache 行(Cache line):Cache 以行为单位管理,M7 Cache 行大小 32 字节。刷 Cache、无效化都必须按 Cache 行操作。

两个关键操作:

  1. Clean(刷回) :把 D‑Cache 里修改过的脏数据写回 SRAM,Cache 保留副本。
  2. 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 发送)

流程:

  1. CPU 填充发送缓冲区 buf,数据写到D‑Cache,还没有刷到 SRAM(Write‑back 写回模式)。
  2. DMA 启动,直接去 SRAM 搬运数据。 👉 SRAM 里还是旧数据!DMA 发出错误旧数据。

✅解决:DMA 发送之前,Clean(刷 D‑Cache),把 Cache 脏数据强制写回 SRAM。

场景 2:DMA 往缓冲区写数据(外设→内存,DMA 接收)

流程:

  1. DMA 把收到的数据写入SRAM
  2. 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 就出错。复现很难。

四、额外约束

  1. Cache 操作是以**Cache 行(32 字节)**为单位,缓冲区起始地址、大小最好按 Cache 行对齐。 如果缓冲区跨两个 Cache 行,Clean/Invalidate 会影响相邻内存,造成数据破坏。

缓冲区要做对齐:__attribute__((aligned(32)))

  1. 不要把 DMA 缓冲区放在 Cache 禁止区域(如果开启 MPU),也可以 MPU 配置该内存区域关闭 D‑Cache,绕开一致性问题。

MPU 设置内存属性为 Non‑cacheable,CPU 访问直接走 SRAM,不需要 Clean/Invalidate,代价是 CPU 访问速度下降。产品常用方案。

  1. 乒乓缓冲同样要处理 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,帧结束标记

关键概念:

  1. 显性位 (0)、隐性位 (1) :总线差分,多个节点同时发送,显性优先覆盖隐性,实现非破坏性位仲裁。ID 越小优先级越高。
  2. 远程帧:没有数据段,RTR=1,用来请求其他节点发送对应 ID 报文。
  3. CAN 最大有效负载 8 字节(传统 CAN2.0);CAN FD 支持最大 64 字节。
  4. 波特率:125k/250k/500k/1M,总线最长距离和波特率负相关。

总线仲裁机制

多个节点同时发报文,逐 bit 对比 ID;发送隐性但是监测到总线为显性,节点立刻停止发送,退出竞争。ID 数值越小优先级越高,不浪费时间,非破坏性仲裁

二、CAN 物理层要点

  1. 差分总线:CAN_H、CAN_L;差分电压来表示 0/1,抗干扰能力极强。
  2. 总线两端必须接 120Ω 终端电阻,阻抗匹配,抑制信号反射。
  3. 多节点挂载,最多 110 个节点。
  4. 总线短路、单线损坏,部分场景还可以降级通信。

三、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 核心优势

  1. 差分信号,抗干扰能力强,适合板间、设备之间远距离通信,I2C/SPI 大多只用于 PCB 板内。
  2. 硬件自带完整错误检测机制:CRC 校验、ACK 应答、错误计数器;检测到错误自动重发。I2C 只有简单 ACK,SPI 完全没有硬件校验。
  3. 硬件仲裁机制,多个节点同时发送不会冲突,ID 决定报文优先级,适合多节点分布式系统。I2C、SPI 没有硬件仲裁。
  4. 故障隔离:节点发生严重错误,会自动进入总线离线,不会把整个总线搞瘫痪。I2C 从机异常直接锁死整条总线。
  5. 支持多节点总线组网,不需要像 SPI 那样每个设备分配独立片选引脚。

五、CAN 劣势

  1. 硬件成本高,需要 CAN 控制器 + CAN 收发器;I2C/SPIMCU 内部自带外设,只需要简单外围。
  2. CAN2.0 一帧有效数据最多 8 字节,大数据传输效率低;需要分包。
  3. 协议复杂,寄存器配置、错误处理、滤波需要软件处理。

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 会报格式错

三个关键新增位

  1. FDF(FD 格式位) FDF=0:传统 CAN 帧;FDF=1:CAN‑FD 帧。用来区分是普通 CAN 还是 CAN‑FD 报文。
  2. BRS(Bit‑Rate Switch,比特率切换位,重点)
  3. 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 节点可以识别帧结束。

⚠️关键点:

  1. BRS 只是帧内的通知标记,硬件控制器必须预先配置两套波特率:仲裁时序、数据段时序;BRS=1 只是触发控制器切换到另一套时序寄存器Microchip。
  2. 总线所有 CAN‑FD 节点必须配置相同的两套波特率,否则通信乱码。
  3. BRS=1 只是 CAN‑FD 帧才有效;传统 CAN 帧不存在 BRS。

三、CAN‑FD 带来的优势

  1. 单帧最大 64 字节,减少分包,降低协议开销,提升总线有效吞吐量。传统 CAN 传输 64 字节需要 8 帧,CAN‑FD 只需要 1 帧。
  2. BRS 实现帧内变速:仲裁段保证总线仲裁可靠性;数据段高速传输,兼顾可靠性与速度。
  3. CRC 升级,长报文错误检测能力提升。
  4. 物理层兼容原有 CAN 收发器,不需要更换硬件。

四、工程踩坑点

  1. ISO‑CAN‑FD vs Non‑ISO(经典坑) 早期 Non‑ISO CAN‑FD CRC 填充规则和标准 ISO11898‑1 不一致,新旧控制器直接对接会报 CRC 错误,硬件必须选择 ISO 模式。
  2. 网络中混有传统 CAN2.0 节点: 传统 CAN 节点识别不了 CAN‑FD 帧(FDF=1),会报格式错误,持续报错。混合网络不能发 CAN‑FD 报文,只能用 CAN2.0A/B
  3. DLC 大于 8 时,CAN‑FD DLC 依旧是 4bit (0‑15),DLC=9~15 映射到 12/16/20/24/32/48/64 字节,不是 DLC 等于实际字节数,这点很容易写错。
  4. 虽然收发器兼容,但高速数据段(4M/8M)对布线、终端电阻、信号完整性要求更高。

16、中断上下文注意事项,中断里面哪些操作禁止执行

中断上下文:进入中断服务函数 ISR,此时不在任务 / 主循环上下文;抢占普通任务,优先级高。 RTOS 环境(FreeRTOS):区分中断上下文任务上下文;中断里不能调用任务层 API。

一、中断上下文核心特点

  1. 中断会抢占主函数、普通任务,执行时间越短越好,禁止长耗时操作
  2. 栈使用:Cortex‑M 默认使用 MSP 主栈,不是任务栈。
  3. 中断嵌套:高优先级中断可以抢占低优先级中断;同优先级一般不能嵌套。
  4. 中断内访问全局共享变量,和主循环存在并发访问,会产生数据撕裂

二、中断服务函数里面禁止做什么(重点)

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 会触发任务切换,中断上下文不允许任务挂起 / 阻塞。

✅中断允许调用不带阻塞的中断专用 APIxQueueSendFromISR()xTaskNotifyFromISR(),结尾带FromISR;并且传入pxHigherPriorityTaskWoken参数,最后调用portYIELD_FROM_ISR()

不带 FromISR 的普通 API,绝对不能在中断中调用,会系统崩溃。

4. ❌禁止大循环、复杂业务逻辑、大量数据处理

中断只做:硬件相关的紧急处理,标记、拷贝少量数据,通知任务。 复杂解析、协议处理、数据运算全部扔到主循环 / RTOS 任务。

中断执行时间过长,会屏蔽其他中断,丢串口、CAN、ADC 等外设中断,造成丢数据。

5. ❌不要在中断和主循环之间共享变量不加保护

全局变量同时被 ISR + 主循环访问,会产生数据撕裂。 处理手段:

  1. 单字节简单标志:加volatile
  2. 多字节变量(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。

三、中断里面推荐做什么

  1. 读取外设寄存器,拷贝少量数据;
  2. 更新简单状态标志;
  3. RTOS:调用xxxFromISR版本 API,向任务发送队列、事件通知,把业务交给任务;
  4. 清除中断标志位(注意:有些标志写 1 清零,不要重复清)。

核心原则:中断快进快出,只处理硬件紧急事情,复杂业务交给任务 / 主循环

四、常见踩坑现象

  1. 中断里面 HAL_Delay,系统卡死,其他中断不响应;
  2. 中断中调用普通xQueueSend(),直接 HardFault;
  3. 主循环与中断共用全局结构体,数据随机错乱(数据撕裂);
  4. 中断中 malloc,偶现堆损坏死机。

17、手撕代码,求两个链表交点

链表相交:两个链表存在公共节点,节点地址相同,不是值相等。 相交之后后面所有节点全部重合,Y 字形;不会出现 X 交叉。

cpp 复制代码
typedef struct ListNode {
    int val;
    struct ListNode *next;
} ListNode;

思路 1:双指针法,最优,O (m+n) 时间 O (1) 空间(面试手撕首选)

算法逻辑:

  1. 指针 pA 指向 A 头,pB 指向 B 头;
  2. pA、pB 同时向后走;
  3. pA 走到末尾 NULL,则跳到 B 链表头;pB 走到末尾 NULL,则跳到 A 链表头;
  4. 循环直到 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:先求长度,快慢对齐(容易理解,也常考)

  1. 分别求链表 A、B 长度 lenA,lenB
  2. 长链表指针先走差值步,使得两个指针到末尾距离相等
  3. 两个指针一起向后遍历,第一个相等地址就是交点
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;
}
相关推荐
Messy create1 小时前
【BMS-Pack检测-短路检测】
单片机·嵌入式硬件·能源
星一工作室2 小时前
【STM32模块教程】-TT马达(直流电机)
单片机·嵌入式硬件
jianqiang.xue3 小时前
ESP-IDF保姆级入门19|低功耗模式全解与工程化设计:分级休眠/唤醒源配置/功耗调优/外设适配,实现工业级功耗性能平衡
stm32·单片机·物联网·架构·esp32
单片机仿真设计3 小时前
【proteus仿真】基于 STM32 单片机汽车安全监控装置设计(仿真 + 源码)
stm32·单片机·嵌入式硬件·proteus·毕设
单片机仿真设计3 小时前
【proteus仿真】基于 STM32 单片机的智能浴室系统设计(仿真图+程序)
stm32·单片机·嵌入式硬件·proteus·毕设
天空'之城3 小时前
单片机基础核心知识点汇总(二十六)
单片机·嵌入式硬件
新晨单片机设计3 小时前
S005A-基于STM32单片机震动防盗报警器【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus
wdfk_prog3 小时前
canopennode-rtt推荐,不只可以做从站,也可以承担主站角色
c语言·开发语言·数据库·学习·算法·深度优先
无小道3 小时前
C/C++——可变参数
c语言·开发语言·c++·可变参数