嵌入式知识日常问题记录及用法总结(一)
文章目录
- 摘要
- 问题一、内核启动流程
- 1.1 ARM内核
- 上电复位与BootROM执行
- 启动代码(Startup Code)执行
- 跳转到用户程序
- 1.2 内存管理
- 问题二、C语言基础
- 2.1 常量指针和指针常量区别
- 2.2.函数指针和指针函数区别
- 2.3 关键字Volatile
- 2.4 队列结构体数据
摘要
嵌入式C语言中,一些常见的问题总结。
问题一、内核启动流程
1.1 ARM内核
Reset_Handler PROCEXPORT Reset_Handler [WEAK]IMPORT SystemInitIMPORT __mainLDR R0, =SystemInitBLX R0LDR R0, =__mainBX R0ENDP
上电复位与BootROM执行
-
复位向量定位:
芯片上电后,ARM内核从固定地址(通常为
0x00000000
)读取复位向量(Reset Vector)。该地址存放初始栈指针(MSP) 和复位处理函数地址(Reset_Handler)。 -
BootROM初始化:
内置的BootROM固件执行以下操作:
-
初始化时钟、内存控制器、关闭看门狗和中断。
-
根据启动模式(如Flash/SD卡启动)加载下一阶段代码(如Bootloader或用户程序)。
-
启动代码(Startup Code)执行
启动代码(通常由工具链或芯片厂商提供,如IAR或Keil的startup_*.s
)依次完成以下任务:
-
堆栈初始化:
- 设置主堆栈指针(MSP),指向栈顶地址(如
sfe(CSTACK)
)。
- 设置主堆栈指针(MSP),指向栈顶地址(如
-
硬件初始化:
- 调用
SystemInit()
函数,配置系统时钟、FPU(若启用)、电源管理模块,并关闭看门狗。
- 调用
-
数据段初始化:
-
.data
段(初始值非零的全局变量):从Flash拷贝初始值到RAM。 -
.bss
段(未初始化或初始值为零的全局变量):将对应RAM区域清零。
-
-
中断向量表重定位:
- 将中断向量表从Flash复制到RAM(可选),并更新VTOR(向量表偏移寄存器)。
跳转到用户程序
-
调用
main
函数:通过
__main
或__iar_program_start
(IAR)等函数完成C运行时环境初始化,最终跳转至用户main()
函数入口。 -
硬件层:BootROM初始化基础外设,加载启动代码。
-
固件层:启动代码配置堆栈、时钟、数据段,建立C运行环境。
-
软件层:跳转
main()
执行用户逻辑。
1.2 内存管理
对于FLASH来说,存在有
RW-DATA:可读可写数据,也就是全局变量,指的是存储的初始值。例如int x = 10;初始值是:10。
因为全局变量需要整个声明周期使用,因此在运行的时候,直接将这一部分复制到RAM空间,方便快速调用,相当于是用空间换时间。
RO-DATA:表示只读数据,指的是常量数据。如:const修饰的全局变量,字符串字面量,以及编译器生成的常量表。通常不需要搬运到RAM,这是因为RO-DATA在运行期间是不会修改的,无需使用RAM的可读可写特性,相当于是节约RAM的使用空间,同时Flash掉电不丢失数据,保证常量持久化。
若RO-DATA被高频访问(如实时解码表),且Flash访问速度慢(如某些NAND Flash),可将其复制到高速RAM(如片内SRAM)以加速访问。
ZI-DATA:仅统计未初始化/零初始化的全局和静态变量,未初始化的全局变量默认状态是0。
相对应的在启动的时候,需要将RW-DATA和ZI-DATA复制到RAM空间,RAM空间是用来运行的空间,RAM从上到下是:栈空间、堆空间、.data、.bss段。
在程序运行的时候,裸机嵌入式系统(如 STM32):CPU 直接从 Flash 读取指令执行(程序编译后,函数代码(机器指令)存储在 .text
段),无需搬运到 RAM(称为 XIP 技术)。函数调用时,栈帧(Stack Frame)保存 返回地址、参数、局部变量,而非函数代码本身,那而对于函数来说:
函数调用 → 动态轻量化:用栈帧实现“按需分配、自动回收”,以极低成本支持嵌套调用、递归、中断等动态场景。
在调用我们写的程序函数的时候,我们需要处理函数内部的局部变量、还需要知道一件事情那就是我们调用以后还需要返回到调用的地方(返回地址,函数执行完应该跳回的位置)、还有参数传递(需传递给被调函数的值)以及寄存器保护(防止被调用函数破坏调用者寄存器)。这些内容每一个函数调用都是不一样的,并且还是循环调用,那么我们就需要设计一个位置最好是能反复应用,相当于是一个车站,可以供天南海北的人去往各地,然后又能回来。那么这个地方就是RAM的栈空间,或者是堆空间。
在栈空间:为当前调用的函数分配一个栈帧,该栈帧是一个连续的内存块,包含上述所有信息,并且这个是自动管理的,函数进入时栈帧入栈,退出时自动释放(仅需移动栈指针SP),避免手动内存管理错误。递归或多层调用时,栈帧按调用顺序叠加,后调用的先释放(LIFO特性)。
栈溢出(递归过深)会导致程序崩溃,栈空间耗尽会覆盖其他内存区域(如全局变量)。此外:栈帧的隔离也会保证程序稳定。
问题二、C语言基础
2.1 常量指针和指针常量区别
常量指针:char const * p : 构成是 const 是常量,而 * p 是指针,因此是常量指针叫法。
首先 * p 是一个指针,也就是一个地址,直接分析似乎不是那么好分析,姑且先将常量作为重点,也就是最中这个地址存的数据是不能被改变的,所以常量是在前面。
应用在:函数参数传递只读数据(如字符串、配置结构),避免内部误改。
指针常量 char * const p:指针常量,就理解成指针是一个常量,那么这就简单了,指针是常量,也就是地址是常量,那么就说明地址是不能改变的,所以是右定向,这样就只需要刷新地址上面的数据就可以了。
应用在:固定资源访问(如硬件寄存器地址不可变,但需修改寄存器值)。
2.2.函数指针和指针函数区别
函数指针:就是我们声明了一个指针,只不过指针类型是函数。
指针函数:本质是函数,返回类型为指针,用于返回地址(如动态内存地址)。
类型 | 声明形式 | 关键语法特征 |
---|---|---|
函数指针 | int (*ptr)(int, int) | * 与指针名用括号绑定:(*ptr) |
指针函数 | int* func(int size) | * 紧贴返回类型:int* |
维度 | 函数指针 | 指针函数 |
---|---|---|
本质 | 变量(存储地址) | 函数(返回地址) |
内存位置 | 数据段(全局)或栈(局部) | 代码段(函数体) |
安全风险 | 错误跳转导致崩溃 | 返回无效指针(悬空指针、内存泄漏) |
典型用途 | 回调、插件系统、状态机 | 内存分配、硬件抽象、数据查询 |
-
函数指针:
-
需确保调用函数与指针签名一致(参数类型、返回值);
-
避免悬空指针:若指向的函数被释放,调用会导致崩溃;
-
-
指针函数:
-
禁止返回局部变量地址:函数退出后局部变量失效,返回其地址引发未定义行为;
-
动态内存需手动释放:返回
malloc
的指针时,调用者必须free
避免内存泄漏。
-
并且为函数指针成员(如SendBefor
、SendOver
、ReciveNew
)赋值为0
,本质上是将其初始化为空指针(NULL
),表示该指针当前不指向任何有效的函数。
0
在指针上下文中等价于NULL
(标准库中通常定义为((void*)0)
),表示无指向目标。
例如:g_tUart0.SendBefor = 0;
等价于 g_tUart0.SendBefor = NULL;
,表明该回调函数当前未设置。
2.3 关键字Volatile
-
-
禁止编译器优化:
编译器在优化代码时可能将变量值缓存在寄存器中(假设其未被修改)。
volatile
强制每次访问变量时直接读写内存地址,确保获取最新值。
-
-
应对“外部修改”:
在嵌入式系统中,以下场景需用
volatile
:-
硬件寄存器(如串口状态寄存器):值可能被硬件自动更新。
-
中断共享变量:主程序与中断服务程序(ISR)共同访问的变量(如
usRxCount
)。 -
多任务共享变量:在RTOS中,多个任务间共享的变量。
-
-
在结构体中的应用
结构体中
usTxWrite
、usRxCount
等成员用__IO
修饰,是因为:-
它们可能被中断服务程序修改(如接收数据时更新指针)。
-
主循环需实时感知其变化(如判断是否有新数据)。
-
本质就是硬件寄存器(如串口状态寄存器)通常被映射到特定的物理内存地址,即 内存映射I/O(Memory-Mapped I/O),也就是硬件设计时将串口控制器的寄存器(如STM32的状态寄存器 USART_SR
、数据寄存器 USART_DR
)分配固定的物理地址(如USART_DR寄存器地址 0x40004400
)程序通过读写这些内存地址,等同于直接操作硬件寄存器。
之前我们文章中也说明了,CPU中包含通用数据寄存器,并且还是循环使用的,我觉得在这里可以衔接起来理解,用于暂存运算数据(如 MOV R0, [R1]
将内存数据加载到寄存器R0),这样就穿起来了。
而对于有些硬件寄存器是外设的物理存储单元,与CPU寄存器无关,就是上面这一段说的如串口状态寄存器通常被映射到特定的物理内存地址,这个如何映射的过程,我觉得暂时可以先不用深究,因为触及到芯片核心了,并且之前我也有在文章中提到。
那么知道了上面的知识,我们才能理解关键字volatile
具体是啥意思。想想一下我们的应用场景,假设我们的串口在很快的速度发送数据,我们使用数组或者链表、队列去接收数据,然后正常的流程是CPU的通用寄存器通过串口的硬件寄存器获取的数据存到R0,然后再进行处理,这样就会有一个问题,串口数据很快,因为CPU需要转手的原因存在一种情况这边跟不上接受的速度,可能会导致部分数据混乱或者时序出问题。
总结一下就是下面的情况:
-
缓存到寄存器:假设状态寄存器值不变,将首次读取的结果缓存到CPU寄存器,后续读取直接复用缓存值(而非重新访问内存地址)。
-
删除“冗余”操作:若多次写入同一寄存器(如循环中写数据寄存器),编译器可能合并为最后一次写入(认为中间写入无效)。
程序无法感知硬件实时状态变化,例如:
-
等待串口数据时,因状态寄存器
RXNE
位未被更新,陷入死循环。 -
发送数据时,因写入操作被合并,实际只发送最后一字节。
那么这个时候关键字就应运而生,直接禁用缓存,CPU通过内存访问指令(如LDR)直接操作硬件寄存器,而非将数据复制到CPU寄存器后操作。
此前也有分析过这个地方, 就是关于按键信息的标志位,也是通过硬件获取的,那么如果没有使用关键字就会导致在编译的时候被优化掉。
成员类别 | 成员名 | 功能说明 |
---|---|---|
通信配置 | Com | 标识串口号(如COM1 ),用于选择物理外设。 |
缓冲区管理 | *pTxBuf | 指向发送缓冲区的指针,存储待发送的数据。 |
*pRxBuf | 指向接收缓冲区的指针,存储已接收的数据。 | |
usTxBufSize | 发送缓冲区总容量(单位:字节)。 | |
usRxBufSize | 接收缓冲区总容量(单位:字节)。 | |
指针与计数器 | usTxWrite | 发送缓冲区写入位置索引(下次写入的位置)。 |
usTxRead | 发送缓冲区读取位置索引(下次发送的位置)。 | |
usTxCount | 待发送数据的剩余字节数(= usTxWrite - usTxRead )。 | |
usRxWrite | 接收缓冲区写入位置索引(新数据存储位置)。 | |
usRxRead | 接收缓冲区读取位置索引(用户已读取位置)。 | |
usRxCount | 接收缓冲区中未读取的新数据字节数(= usRxWrite - usRxRead )。 | |
回调函数 | SendBefore() | 发送前回调(用于切换RS485为发送模式)。 |
SendOver() | 发送完成回调(用于切换RS485为接收模式)。 | |
ReciveNew(_byte) | 收到单字节数据时触发,用于实时处理或协议解析。 |
2.4 队列结构体数据
在创建不同结构体队列的时候,原本是想使用队列通用函数统一管理,但是发现在通用管理中,我们固定了该数据结构体的类型,这样就导致如果使用不同的结构体就会很麻烦。除非我们在通用结构体中尽可能的广泛包含对应的数据类型。这样似乎就可能导致冗余?因为本身我们想通过通用队列降低尽可能多的语法糖,但是反而会增加。
仔细分析队列处理函数,我们可以发现并没有很复杂的逻辑,换句话会说,我们直接实现相关函数也不是不可以。其实使用队列主要就是对数据的两次判断,一次是判空,一次是判满,
判空指的是:读的速度很快,接受的很慢。
判满指的是:读的速度或者处理的速度比较慢,接收的就很快。
也就是说在装填的时候,我们怕队列满,所以需要判满。
在读取的时候,我们怕队列空,所以需要判空。
对于使用队列作为数据缓冲,是嵌入式开发中最常见的形式。但在超高吞吐量(双缓冲)、极低延迟(无锁缓冲)或**极简需求(静态变量)**时需选用替代方案。
先进先出(FIFO) ,First-in First-out。
队列数据必须包含四个成员:
队头索引、队尾索引、队列长度、队列缓存数组,其他的可以根据自行需要进行适当扩展。
并且在使用的时候必须要进行队列判满和判空 判断。
缓冲区不是免检区。
-
数据完整性的保障
-
队列满时写入:若不判断队列满状态,新数据会覆盖未处理的旧数据,导致数据丢失(如传感器实时数据流被覆盖)。
-
队列空时读取:若未判断空状态,可能读取无效数据或随机内存值,引发程序逻辑错误。
-
-
资源冲突与系统稳定性
-
缓冲区溢出:持续写入未判满会导致缓冲区溢出,可能破坏相邻内存区域(如栈溢出),造成系统崩溃。
-
多线程/中断场景:在中断服务程序(ISR)中,若未同步判满/空逻辑,可能因竞争条件(Race Condition)引发数据错乱。
-
-
性能与效率优化
- 避免无效操作:判空可跳过无意义的出队操作,减少CPU浪费;判满可触发流控机制(如暂停生产者线程),避免忙等待。
在C语言中,取模(Modulo)和取余(Remainder)本质上是同一个操作,均使用 %
运算符实现,且语言标准未明确区分两者。但在处理负数时,C语言的取余行为与数学上的模运算存在差异。也就是需要手动去处理这个取模和取余的区别。因为取模必须为正。
在正数的时候取模和取余是没有任何区别的。因为都是正数,不存在有什么正负号区别。
C语言取余规则(%
运算符)
符号规则:余数的符号始终与被除数(第一个操作数)相同,与除数无关。
- 示例:
-10 % 3 = -1
(被除数-10
为负 → 结果负)10 % -3 = 1
(被除数10
为正 → 结果正)-10 % -3 = -1
(被除数-10
为负 → 结果负)
模运算是得到的结果是非负的。
![[Pasted image 20250717150958.png]]
所以在负数取模和取余是有区别的
![[Pasted image 20250717151027.png]]
模运算一定是正的结果。
取余运算要根据被除数正负号来确定。
模运算或者取余对于正数来说可以回到起点,这样就形成了一种将线性数组映射为环形结构,相当于原本是一个线性的可能需要无线递增的数组,当我定义了两个指针以后,尾指针或者是头指针遍历到最后一个以后,我取模运算,这样相当于是数组的下标又变成了零,这样就又可以重新填充数据或者是覆盖原来数据,等于可以循环使用这个数组。这是因为数学的取模运算实现了循环。也就是这句话:”循环队列通过模运算将线性数组映射为环形结构。“
何为映射,描述了两个集合元素之间的一种定向对应关系。循环队列通过模运算将线性数组映射为环形结构,应该怎么理解这个映射?
本质是通过数学同余关系在逻辑上实现索引的循环回绕,从而用普通数组模拟环形存储。
模运算(a % n
)的核心性质是将任意整数映射到范围 [0, n-1]
的同余类中。
- 索引回绕公式:
- 向后移动:
新索引 = (当前索引 + 步长) % 数组长度
- 向前移动:
新索引 = (当前索引 - 步长 + 数组长度) % 数组长度
- 向后移动:
- 示例(数组长度
n=5
):- 当前索引
4
,向后移动一步:(4+1) % 5 = 0
(回到数组起点) - 当前索引
0
,向前移动一步:(0-1+5) % 5 = 4
(跳至数组末尾)
- 当前索引
在实际使用过程中还会有一些问题:
假如我们设计的数组长度是10,那么其实使用数组下标访问的时候是:09,那么如果我们访问到最后一个的试试其实是a[9],那么使用这个数字进行取余或者取模操作得到的还是9,并不会回到原点,因此就需要将我们的idex手动+1取余操作。或者从另外一个角度,我们第一次访问的肯定是0位置这个数据,但是接下来就需要访问1这个位置的数据,好像不对这样想~,不管如果是单纯的回到原点都是可以的。
但是另外一点解决不了,那就是重合的时候不能区分哪一种情况。
第一种是读的速度追上写的速度,这样就会造成空。
第二种是写的速度追上读的速度,这样就会造成满。
无外乎这两种情况,所以要根据这两个情况进行分析。
循环队列分析及应用-CSDN博客
在分析读的速度追上写的速度的时候,此时的数组一定是有很多的空余空间的,所以写的下一位一定是空的。
在分析写的速度追上读的速度的时候,此时的数组一定是没有剩余空间了,并且此时两个数组的下标是重合的。那么在下标重合之前一定有一个动作是写位置和读位置有一个空白间隔。那么这个==写后面有一个空白位置其实就是和上一个读的速度追上写的速度情况保持了一致,也就是能否在这个情况下进行分析,从而找到区别这两种情况的办法。
为什么要这样,我们的目的是控制变量法,就是区分相同情况的两个事件,那么就一定需要找到他们两个事件的共同点,从而在共同点的情况下去分析区别这两个情况的,本例子的共同点就是:写后面有一个空白位置==。
在第一种情况下,大前提一定是在读数据的时候触发的,那么此时只要满足
QueueStatus_t QueuePop(QueueType_t *queue, uint8_t *pdata)
{if(queue->head == queue->tail){return QUEUE_EMPTY;}*pdata = queue->buffer[queue->head];queue->head = (queue->head + 1) % queue->size;return QUEUE_OK;
}
我们不用关心写后面的空白位置,因为在这种空的情况下是一定满足的,所以只需要这样判断那么一定就是该数据要空了。
接着在考虑第二种情况,此时的大前提一定是在写数据的时候,这个时候注意了,写数据的时候如果我们不干预,他会一只写,毕竟任何限制都没有无外乎进行循环操作。就算写满了空闲位置,还没有来得及读,一样会继续写,覆盖掉未处理的时候,只要不加限制。所以这种情况我们要人为的制造出来一个:写后面有一个空白位置,因为只有这个保持一致,我们才能和第一种情况区分,否则没有同维度下的判断,我觉得类比是没有意义的。
所以我们的代码写的思路首先是将写数组的下标进行自增1。此时index指向的就是下一个当前写数据的 下一个位置。
uint32_t index = (queue->tail + 1) % queue->size;
由于我们认为规定了,在写数据的时候,相对于当前位置的下一个位置必须为空,这个写有意义,否则就是没意义的。那么相反当在写数据的时候,相对于当前位置的下一个位置是当前正在读数据的位置时候,就表明此时数组已经满了。所以我们就找他的想反面,那我们为什么不直接找“在写数据的时候,相对于当前位置的下一个位置必须为空”这种情况呐,因为这个情况不好把握呀,很多种情况都满足,也就是说写的时候不好控制,但是我们可以控制溢出条件,因为溢出条件只有一种,那就是“那么相反当在写数据的时候,相对于当前位置的下一个位置是当前正在读数据的位置时候,就表明此时数组已经满了” 所以才有了这段代码
uint32_t index = (queue->tail + 1) % queue->size;if (index == queue->head){return QUEUE_OVERLOAD;}queue->buffer[queue->tail] = data;queue->tail = index;return QUEUE_OK;
这个条件其实就是表明我现在写数据的位置已经到了我们规定不能写数据的位置,所以要退出函数。
如果觉得我的内容对您有帮助,希望不要吝啬您的赞和关注,您的赞和关注是我更新优质内容的最大动力。
专栏介绍
《嵌入式通信协议解析专栏》
《PID算法专栏》
《C语言指针专栏》
《单片机嵌入式软件相关知识》
《FreeRTOS源码理解专栏》
《嵌入式软件分层架构的设计原理与实践验证》
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言,笔者一定知无不言,言无不尽。