FreeRTOS入门知识(初识RTOS)(一)
文章目录
- 摘要
- 一、浅识FreeRTOS快速入门课程
- 1.1、内存管理
- 1.2、FerrRTOS 与裸机区别
- 1.2.1 基于任务创建函数理解内存空间分配
- 1.2.2 基于函数执行的栈空间理解和LR寄存器
- 1.2.3 基于官方例程增加串口打印功能的实现知识点
摘要
代码框架暂时停止更新,因为有一部分内部不是很理解,等完全理解后,重新恢复更新模块化编程思想。
先开一个新坑,FreeRTOS缓慢更新中…
一、浅识FreeRTOS快速入门课程
1.1、内存管理
对于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特性)。
栈溢出(递归过深)会导致程序崩溃,栈空间耗尽会覆盖其他内存区域(如全局变量)。此外:栈帧的隔离也会保证程序稳定。
1.2、FerrRTOS 与裸机区别
1.2.1 基于任务创建函数理解内存空间分配
在裸机程序中,代码在Flash中线性执行,也就是我们常说的C语言顺序执行,并且在main函数中有一个死循环,一直在轮询的执行。如果发生中断,就会打断主循环,完成中断的处理以后,就会继续回到原来位置执行。依靠很好的分层去调用降低阅读理解。但是如果依赖全局变量或者标志位,无任务隔离,就会因为逻辑耦合导致阻塞,在调试的时候很复杂。
但是使用了执行框架,也就是FreeRTOS,Flash存储的是内核代码和任务函数,但是执行逻辑由调度器动态管理。通过Scheduler实现多任务并发,每个任务独立运行,拥有个人的栈空间和状态(运行,就绪,阻塞,挂起),调度器基于优先级抢占和时间片轮转,动态选择执行任务。
每一个任务就是一个任务块(TCB),包含优先级、栈指针、状态等数据,并且还需要局部变量、函数调用链上下文环境等放在对应的函数栈。任务函数例如:传感器采集任务。
需要注意的是任务函数一定是不需要返回值的,若终止则需要显示调用vTaskDelete(NULL)
。
状态 | 含义 | 管理方式 |
---|---|---|
运行态 | 当前占用CPU的任务(单核仅1个) | 直接由CPU执行 |
就绪态 | 可运行但未获CPU的任务,按优先级分组存储于就绪链表 | 调度器选择最高优先级任务执行 |
阻塞态 | 等待事件(如信号量、队列)或延时,移入阻塞链表 | 事件触发后移回就绪链表 |
挂起态 | 被显式挂起(vTaskSuspend() ),不参与调度 | 需调用 vTaskResume() 恢复 |
任务调用 vTaskDelay(100)
→ 从运行态进入阻塞态(延时链表)→ 延时结束移回就绪态 → 调度器根据优先级决定是否抢占。
通过抢占式调度满足实时性,时间片轮转保证公平性,协作式调度兼容旧设备。
[前缀]_[对象]_[动作]
BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, // 函数指针, 任务函数const char * const pcName, // 任务的名字const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小,单位为word,10表示40字节void * const pvParameters, // 调用任务函数时传入的参数UBaseType_t uxPriority, // 优先级TaskHandle_t * const pxCreatedTask ); // 任务句柄, 以后使用它来操作这个任务
任务函数要传进去,并且是死循环,不能有返回值。永远不退出,或者退出时要调用
“vTaskDelete(NULL)”
任务名字,就是我们自己给这个任务起个名字,方便辨识。FreeRTOS不会调用这个名字。
任务需要的空间大小,us表示的是``unsigned short(无符号短整型)
,
我们创建任务需要的参数是在这个里面传入的, void * const pvParameters, 这个地方要注意const在*
的位置,因为参数是不确定的,因此我们就申请一个空间,就用来装填参数,所以右定向(const在 *
的右边),这个空间就是存储参数的,和名字的定义是有区别的的,名字是定值(const在*
的左边),就是不能修改。
这个是不是说,就是在该任务函数的栈帧里面存放, 就是在该任务函数的栈帧里面存放,只不过const修饰是在整个周期存在,那和没有用const修饰的怎么区别,是用一次在该栈帧在分配吗,并且任务函数创建以后栈帧存在,如果不调用,栈帧就被释放吗 还是怎么? 如果释放了栈帧,那么我使用const修饰的变量应该在哪里,或者说只要是Const修饰的就已经在初始化的时候放在了.bss段,栈帧随时用随时调用.bss段的参数?
使用const修饰的任务参数是否会被存放在.bss段?任务栈帧的分配释放如何影响这些参数?这实际上触及了三个技术层面的交叉——C语言存储类别、RTOS任务内存模型、以及编译器的链接优化策略。
拆开进行分析:
对于使用const修饰的变量:
如const char *str = "Task1";
字符串Task1
编译后会存储在RO-DATA(ROM)数据段,这是因为const修饰以后就是常量,不能被修改,因此只能存放在ROM段。
const char *pcName = "Task1"; // 字符串常量在.rodata,指针pcName在.data或.rodata
xTaskCreate(vTask, "Task", 128, (void*)pcName, 1, NULL);
传递的是指针,是不是就是说其实是将pcName存储的(RO-DATA段的地址)传递进去了,相当于使用这个名字的时候,直接访问的是RO-DATA段的地址。
const char *pcName = "Task1";
这句话的意思是Task1字符串在编译时被放置在只读数据段(.rodata)RO-DATA段,他的地址是属于ROM里面的(如果强行修改就是段错误(操作系统保护))。
而指针变量pcName
,pcName
存储的是 ROM 中 "Task1"
的地址,而 pcName
变量本身需额外占用 RAM 空间(位置由作用域决定)。
如果是字符串就很好理解,而字符串常量存储在内存的只读数据段(.rodata) 中。
并且指针符号在const的指针符号*
的左边,表示是定值,
const修饰的变量虽然不能改变,但是和常量还是有一定区别的,如果const修饰的是指针变量,那就要分情况讨论。但是常量一定是在ROM区域。
两个地址要清楚,本身pcName
是存储指针变量,里面存储的一个地址数据(可以是任何地方的,例如是ROM区),但是该指针变量也需要存一个地方,这个地方就不一定了,视情况而定。并且存储pcName地方直接就是内存了,不需要另外一个指针在存储这个变量了,所以本身并没有包含二级的隐式概念,而是本身就不需要另外一指针存储这个地址,这样不就变成一个套娃了。除非你显示的定义二级指针,那也很好理解了,就是相当于将地址的地址给存起来了,就是你上面想的套娃的那个感觉。
那这样就重新理解假如一个变量int a = 0xFFEF,在32为系统中,需要四个字节的内存进行存储给数据,那么我们假设内存空间是0x2000 0000、0x2000 0001、0x2000 0002、0x2000 0003这四个地址,如果栈空间,那么编译器就会在栈内存中分配4个连续的空间用来存储这个变量a。
但是由于一些大佬的设计,我们的指针变量只需要存储该变量的首地址0x2000 0000,至于剩下的怎么执行找到其他三个地址,感觉是编译器的手法或者是硬件的,反正不用深入研究,现在还没有那个水平。 a
的4字节连续空间由栈自动分配,仅需首地址即可访问。
当通过*p
访问int类型数据时,CPU会根据数据类型的大小(例如int 为4字节)自动计算后续字节的逻辑地址,并连续读取或者写入这四个字节,反正就是一起操作。通过地址总线和内存控制器完成。
然后硬件通过地址总线将逻辑地址0x2000 0000转换为物理内存的实际位置。逻辑地址是为了编译器方便管理和编程,但是在实际中还需要再进行映射一次,映射到实际物理地址(通过MMU(内存管理单元)),但是映射到实际物理地址的时候不一定是连续的(在支持虚拟内存的操作系统中,逻辑地址通过 MMU(内存管理单元) 映射到物理地址,物理内存可能被分割为多个页帧(如4KB一页),导致逻辑连续的地址实际分散在不同的物理页中)。
但是对于对字节的数据类型,CPU会确保连续地址的访问是原子的,就是一次性完成读写,避免被中断,即使物理地址不连续。
其实使用指针变量说白了就是方便我们找到这个变量在内存中的位置,直接对内存操作。玩指针说白了就是玩内存,因为指针就是操作内存的载体。
也就是在这一刻,我突然明白了,指针之于C语言到底是什么概念,对于嵌入式软件工程师到底是什么意思。也只有掌握了指针,才能说掌握了C语言,而这只是掌握使用芯片内存单元的基础,而只有了这些基础,才能深入理解FreeRTOS里面的含义。
FreeRTOS是一个专为嵌入式设计的实时操作系统,其通过多任务调度、中断管理和资源同步机制实现了操作系统的核心功能。 再看这句话,我觉得我有了深刻的认识。
以下这个例子说明const修饰的指针变量的:
#include <stdio.h>int main() {int value1 = 10;int value2 = 22;// 初始化指向常量的指针const int *pcName = &value1; // 指向value1的地址printf("初始值: %d\n", *pcName); // 输出: 10// 修改指针指向的地址pcName = &value2; // 指向value2的地址printf("修改后: %d\n", *pcName); // 输出: 22return 0;
}
可以理解为const修饰的是*pcName
这个的数据,那么我们看到*pcName
看到也可以想想成是一种解引用,就是找到这个指针变量存储的值,而const的目的就是保证这个值不能修改。而单独看pcName这个里面存储的是地址,所以还是不一样的,所以从这个角度还是很好区分的。
还有一点就是* pcName
两个用法,定义的时候,就是单纯的作为指针符号使用,说明这个pcName
是一个指针变量,里面储存的是地址,区别于普通变量paName
。不是在定义的时候是相当于解引用。而我们其实可以在自己的内心在出现const的时候将* pcName
简单的理解解引用,这样就能知道是该值不能通过指针进行修改,也是使用const本身的目的,都是为了便于自己理解。但是时刻牢记定义的时候一定是存的地址,并且需要使用取址符号&。
为什么可以修改psName存储的地址,这是因为const是针对的* paName
这个整体,因此不能单独作pcName因此可以更换pcName储存的地址。
BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, // 函数指针, 任务函数const char * const pcName, // 任务的名字const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小,单位为word,10表示40字节void * const pvParameters, // 调用任务函数时传入的参数UBaseType_t uxPriority, // 优先级TaskHandle_t * const pxCreatedTask ); // 任务句柄, 以后使用它来操作这个任务
回到这里,我觉得可以思考一个问题,那就是当创建这个任务的时候,这些变量是在哪些内存中存储,或者说怎么调用。
static const char *pcTextForTask1 = "T1 run\r\n";
static const char *pcTextForTask2 = "T2 run\r\n";int main( void )
{prvSetupHardware();xTaskCreate(vTaskFunction, "Task 1", 1000, (void *)pcTextForTask1, 1, NULL);xTaskCreate(vTaskFunction, "Task 2", 1000, (void *)pcTextForTask2, 1, NULL);/* 启动调度器 */vTaskStartScheduler();/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */return 0;
}
在传递参数的时候,我们可以看到将这个参数进行了强制转换,这是为了增加通用性,允许传递任意类型的数据(如整数、结构体、字符串等)。这种泛型设计提高了代码的灵活性。
但是在内部使用时候又会将他强制转换为我们需要的类型,任务函数内部仍需转换回const char*
才能安全使用字符串,避免未定义行为(如尝试修改只读数据)。
虽然操作多了一层,但是显著的提升了代码的灵活性。这是值得学习的地方,但是需要牢记的是在进入内部以后,我们要将其还原,操作我们原本想操作的数据,只是在传递的时候强转一下,为什么可以呐,这是因为这是一个地址,我可以随便操作。但是在内部以后,我也只能且必须,因为只有知道了什么类型,地址总线和内存管理器才知道怎么操作呐,指针必须有数据类型,这个地方就跟之前的内容互相联系了,环环相扣。
1.2.2 基于函数执行的栈空间理解和LR寄存器
LR(Link Register)
是一个寄存器保存现场使用
int a_fun(int val)
{int a = 8;a += val;b_fun();c_fun();return a;
}int main(void)
{char ch = 65; // char ch = 'A';int i;/*C标准规定 void* 可自动兼容其他数据指针类型,因此无需强制转换。*/char *buf = my_malloc(100);unsigned char uch = 200;for (i = 0; i < 26; i++)buf[i] = 'A' + i;a_fun(46);return 0;
}
执行到a_fun()函数的时候,首先要保存return 0 这个语句的地址,这是因为执行完a_fun函数后需要返回到这里,所以需要保存这个return 0 语句的地址,这个地址存在压入栈顶(即 main
函数的栈帧顶部),注意并没有存储在a_fun
的栈帧,存在LR寄存器。并且会将LR中的地址存到栈中。
同理a_fun()里面需要调用b_fun();,那么在执行b_fun()的时候同样需要保存下一条语句的地址,不然执行完b_fun以后,就不知道怎么执行c_fun()函数了。,这个地址存,存在LR寄存器。同理也是存到栈中。
在main函数中执行到a_fun的时候内存管理单元或者CPU会给a_fun函数在占空间中分配,这是因为每一个函数都是有一个独立的栈空间的,也就是所为的该函数的栈帧,同样里面需要存很多CPU通用寄存器的值和该函数内部的局部变量等。
那么在执行a_fun的时候需要做两件事
1、LR需要存入返回地址,而这个返回地址就是下一行语句。很好理解,我们执行了a_fun以后不就应该执行下一行语句了,因此必须把下一行语句的地址给存起来,这样a_fun函数执行完以后,直接跳转到该地址很舒服。
2、执行a_fun函数。
但是LR寄存器是CPU通用寄存器,因此很容易被覆盖,那么我们就要将此时LR中存的值放入a_fun函数的栈帧中。
当函数执行完成以后,就需要将该函数栈帧空间最上面的地址给取出来,这样就能保证返回到我们需要返回的位置了。
例如执行完b_fun函数以后,就需要取出b_fun函数栈空间中存放的返回地址指向的语句c_fun();执行c_fun函数。而c_fun函数执行以后就会返回return 0 语句的地址,然后执行该语句的内容,这样说明a_fun函数已经执行完成,那么就要在函数a_fun中取出之前保存的return 0 语句的地址,然后执行,这样一个main函数就执行完整了。
这个过程主要是大概内容,并且也映射了SP指向栈顶空间,当然这个栈顶是相对的,每个栈帧内部的栈顶。
每个函数都有自己栈,这也符合自己之前的总结。
数据的理解:
BaseType_t: 这是该架构最高效的数据类型;
32位架构中,它就是 int32_t
16位架构中,它就是 int16_t
8位架构中,它就是 int8_t
BaseType_t通常用作简单的返回值的类型,还有逻辑值,比如 pdTRUE/pdFALSE。并且由于硬件架构的匹配性,32位CPU的寄存器、算术逻辑单元(ALU)和数据总线均为32位宽。处理32位数据时,CPU可在单个时钟周期内完成运算(如加法、乘法),无需额外的数据拆分或拼接操作。若处理8位数据,32位CPU需通过掩码操作或移位指令隔离目标数据位(例如提取低8位),这会增加额外的指令周期。尽管硬件支持,但实际效率低于原生32位操作。因此,在优化得当下,32位的内存开销可控制在可接受范围,且其性能增益通常远大于内存代价。
BaseType_t
是 FreeRTOS 为硬件效率优化的有符号整型,无需替换为无符号类型。
保证了在不同架构中,CPU都是最优运算。
重要的数据类型和前缀缩写:
BaseType_t:有符号的数据,由处理器的位数决定,和处理器保持一致。
而UBaseType_t就是对应的无符号数据。
而TickType_t:是固定的最大的32位数据,用来滴答定时器使用。
char:c
char指针:pc
指针:p
BaseType_t、其他非标准的类型结构体、task_handle、queue_handle等:x
无符号unsignd:u
unsigned char:uc
1.2.3 基于官方例程增加串口打印功能的实现知识点
在增加串口打印功能需要两个思路
-
初始化串口
-
实现fputc函数
int fputc( int ch, FILE *f )
{USART_TypeDef* USARTx = USART1;while ((USARTx->SR & (1<<7)) == 0); USARTx->DR = ch;return ch;
}
这里面也设计到一些技巧,
在没有发送完成的时候SR寄存器的值是0,发送完成以后是1。
如果按照下面的写法:
int fputc( int ch, FILE *f )
{USART_TypeDef* USARTx = USART1;USARTx->DR = ch;while ((USARTx->SR & (1<<7)) == 0); return ch;
}
就意味着我必须要等待发送完成以后才能离开,就导致我不能干其他事情。
相反这种写法:
int fputc( int ch, FILE *f )
{USART_TypeDef* USARTx = USART1;while ((USARTx->SR & (1<<7)) == 0); USARTx->DR = ch;return ch;
}
我直接先判断一下,这个判断就是看上一次的数据是否完成发送,如果上一次完成,那么我就直接这一次开始发送,并且我就直接离开了,可以干其他事情了,我只需要下次发送之前看一下就行了。这样就相当于是节约了时间,优化代码运行的效率,这种思想我觉得FreeRTOS中应该很常见, 特别是在设计任务调度的时候,考察的就是这个能力。
如果觉得我的内容对您有帮助,希望不要吝啬您的赞和关注,您的赞和关注是我更新优质内容的最大动力。
专栏介绍
《嵌入式通信协议解析专栏》
《PID算法专栏》
《C语言指针专栏》
《单片机嵌入式软件相关知识》
《FreeRTOS源码理解专栏》
《嵌入式软件分层架构的设计原理与实践验证》
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言,笔者一定知无不言,言无不尽。