【RTOS学习】模拟实现任务切换 | 寄存器和栈的变化

🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!
图

目录

  • 🏀认识任务切换
    • 🏐切换的实质
    • 🏐栈中的内容
    • 🏐切换过程
  • 🏀实现任务切换
    • 🏐伪造现场
    • 🏐启动任务
    • 🏐切换任务
  • 🏀栈和寄存器变化
    • 🏐创建任务时
    • 🏐任务启动时
    • 🏐任务切换时
  • 🏀总结

🏀认识任务切换

🏐切换的实质

图
如上图所示代码,定义两个任务函数task_atask_b,在mymian函数中调用这两个函数,在调用的时候传入不同的参数。在任务函数中,打印出自己的函数名称后便开始死循环打印各自形参接收到的字符串。

tu
如上图所示,在调用task_a以后,该函数在它的栈中运行,局部变量保存在栈中,在其内部调用的putsputchar函数也会有自己的栈,这两个函数的栈紧挨着task_a的栈。

假设能执行到task_b函数,该函数也有一个栈,进行和上面相同的操作。

  • 每一个函数都对应着一个自己的栈。

而FreeRTOS执行的任务也是函数,它们也有自己的栈,每一个任务就对应着一个自己的栈

裸机程序中,函数在执行的过程中,使用的是函数自己的栈中的内容,各自的操作也是在自己的栈中完成,包括数据的保存,修改等等。

FreeRTOS中不同任务在执行的时候,也是使用任务函数自己栈中的内容,各自的操作也是在自己的栈中完成的。

  • 任务切换的本质,就是切换不同任务的栈让CPU来操作。
  • 任务切换其实就是在切换

🏐栈中的内容

我们知道,FreeRTOS中任务的切换是在SysTick_Handler中断中完成的,也就是说在该中断函数中完成了栈的切换。

首先要知道的就是,在切换栈的时候,栈中有什么!!!

tu
如上图所示,便是在SysTick_Handler中切换任务时,当前任务栈中的内容,包含R0~R3,R12,LR,返回地址,xPSR以及R4~R11这些寄存器中的内容。

在学习中断的保存现场时候本喵讲解过:

  • 在产生中断,调用中断服务函数之前,硬件保存R0~R3,R12,LR,返回地址,xPSR这些寄存器中的数据到栈中
  • 在中断服务函数中,软件保存R4~R11这些寄存器中的数据到栈中

硬件保存不用我们管,软件保存就需要代码来实现了,但是我们在写中断服务函数的时候,从来也没有写过保存R4~R11寄存器的代码,这是因为这部分代码由编译器替我们完成了。

  • 如果中断函数或者普通函数会使用到R4~R11寄存器,那么在一进入函数时需要先保存这些寄存器中的值到栈中,调用完毕时再将原本的值恢复到寄存器中。

🏐切换过程

tu
如上图所示,任务A执行一定时间后产生了Tick中断,在中断函数中切换成了任务B,随后开始执行任务B,执行一定时间后再次在Tick中断函数中切换成任务A,如此反复。

关键就在于SysTick_Handler中断服务函数中到底做了什么:

  • 暂停任务A:保存任务A的现场
    • R0~R3,R12,LR,返回地址,xPSR由硬件保存到任务A的栈中
    • R4~R11由软件保存到任务A的栈中
  • 运行任务B:恢复任务B的现场
    • 任务B栈中的R4~R11寄存器值由软件恢复到寄存器中
    • 任务B栈中的R0~R3,R12,LR,返回地址,xPSR寄存器值由硬件恢复到寄存器中。

🏀实现任务切换

🏐伪造现场

tu
继续看这张图,任务A和B的切换发生在Tick中断函数中,在切换任务执行A的时候,需要恢复任务A的现场,但是在任务A第一次运行的时候,它的现场哪里来的呢?

在第一次执行任务A时,它的现场并不存在,因为没有在执行任务A期间发生过Tick中断,所以任务A的栈中没有硬件保存的R0~R3,R12,LR,返回地址,xPSR寄存器值,也没有软件保存过R4~R11寄存器的值。

  • 所以,需要在创建任务的时候伪造一个该任务的现场,在第一次执行该任务的时候,有现场可恢复

图
如上图所示代码,定义创建任务函数create_task,该函数的第一个参数就是要执行的任务函数,所以使用typedef void(*task_function)(void* param)重名名任务函数指针为task_function,方便后面使用。

在创建任务的函数create_task中伪造该任务现场时,需要的参数有:

  • 要执行的任务函数指针f
  • 传给任务函数的参数prama
  • 属于该任务的栈stack
  • 以及栈的大小stack_len
  • 这里本喵仅实现静态任务创建,由我们自己指定任务的栈。

在函数内部,由于形参stack指向的是栈的起始地址,也就是这段内存的最低地址,但是栈是从高地址向低地址向下生长的,所以需要得到栈顶的位置top,因为存放的16个32位的寄存器值,所以(stack + stack_len)得到的就是栈顶的地址。

下一步就是真正的伪造现场,用值填充这个栈,由于高地址存放的是高编号寄存器中的值这个规则,所以我们从栈底开始依次存放数据。

  • 先存放R4~R11
  • 再存放R0~R3,R12,LR,返回地址,xPSR

存放过程中,必须严格按照前面讲解的SysTick_Handler中断函数中栈中的内容顺序存放。

对于R4~R11,任务第一次启动时并不关心它里面的内容是什么,所以全部设置为0,R0是用来传递任务函数的参数param的,不能设置为0,R1~R3,R12也无所谓,全部设置为0。

对于LR,由于该任务是第一次运行,它必然不会是被其他函数调用的,所以无所谓返回地址,也设置为0。

对于返回地址,这才是Tick中断函数第一次启动任务后,退出Tick中断函数时真正的返回地址。将任务函数的地址放在这里,任务启动并退出中断函数后就会执行相应的任务函数,所以这里放入任务函数的指针f

对于状态寄存器xPSR,虽然任务是第一次执行,之前没有任何状态存在,但是它不能是0,必须将它的第24位置1:

TU
如上图所示,程序状态寄存器xPSR中的第24位T,该位为1表示使用Thumb指令集,为0表示使用ARM指令集,而本喵使用的Cortex-M3只能使用Thumb指令集,所以该位必须是1。

现场伪造完毕以后,需要将该任务的栈记录下来,方便下次切换时能够找到该任务的栈:

  • 创建全局数组task_stacks存放每个任务的栈顶
  • 使用task_count计数任务个数

tu

如上图所示,创建两个大小为1024字节的char类型静态全局数组stack_astack_b,这两个数组就是两个任务的栈。

  • 在常规创建数组语句的后面加上__attribute__ ((aligned (4)))表示让该数组在内存中要4字节对齐

如果不要求4字节对齐的话,在将该数组作为任务的栈时,由于CPU是32位处理器,所以在访问栈时可能由于不是4字节对齐而出现读取或者写入错误,导致程序出现问题。

  • 如此,在创建任务的时候就完成了现场伪造。

🏐启动任务

tu
如上图代码所示,在创建好两个任务之后,任务的启动也是在Tick中断函数中,该中断函数本喵定义成一个汇编函数SysTick_Handler_asm

Tick中断产生后,硬件会调用SysTick_Handler_asm中断服务函数,在一进入中断函数时,硬件已经完成了R0~R3,R12,LR,返回地址,xPSR寄存器的保存,将这些寄存器中的值保存到了栈中。

但是R4~R11寄存器中的值硬件并不管,需要软件完成,所以在一进入中断函数时,就使用STMDB SP!, {R4 - R11}汇编指令将R4~R11的值保存到栈中。

由于是中断函数,所以此时LR寄存器中的值并不是返回地址,而是那个特殊值,由于在后面会使用BL SysTick_Handler调用C函数来实现任务栈的切换,会改变LR中的值,所以这里要将此时LR中的特殊值也保存到栈中。

LR中的特殊值赋给R0进行传参,将真正栈(不包括栈中的LR)赋值给R1作为另一个参数进行传参。

  • 在任务没启动时,现场保护所操作的栈并不是我们指定的任务栈,而默认的栈。

图
如上图代码所示,这是Tick中断中调用的回调C函数SysTick_Handler,在函数内部,先调用is_task_running()判断任务有没有创建好,如果没有创建好就直接返回,此时这仅仅是一次普通的Tick中断。

如果任务创建好了,根据cur_task当前任务的值判断这是不是第一次启动任务,如果该值是-1,说明这是第一次启动任务,调用get_stack从记录任务栈的数组中得到第一个任务的栈,然后调用StartTask_asm函数将该任务的栈切换过来,开始执行。

  • 调用StartTask_asm传的参数中,stack是指定要切换的栈,lr_bak是特殊值,是为了软件恢复完毕后触发硬件恢复。

tu
如上图所示代码,StartTask_asm函数本喵同样定义成了一个汇编代码,在一进入该函数时,就将存放在要启动任务栈中的R4~R11值恢复到对应的寄存器中。

  • R0寄存器中的值是在调用StartTask_asm时传过来的,表示要启动任务的栈。

软件恢复完毕后,此时的R0指向栈中存放硬件要恢复的R0值所在的位置,所以使用MSR MSP, R0将该栈指针赋值给MSP,让真正的栈顶指针SP来管理这部分栈。

然后使用BX R1跳转,由于R1中的是一个特殊值,所以触发了硬件恢复,将栈中剩下的R0~R3,R12,LR,返回地址,xPSR值恢复到了对应的寄存器中。

  • 由于此时恢复的现场是我们伪造出来的,所以返回地址是该任务要执行的任务函数。

此时第一个任务就执行起来了。

补充用到的功能函数:

tu
如上图所示是用到的功能函数,这些函数都放在task.c文件中。启动任务只是将全局变量标志task_running置一,然后陷入死循环,此时在发生Tick中断时,通过该标志就可以知道任务是否创建成功,因为任务没有创建的时候,也有可能发生Tick中断。

🏐切换任务

tu
如上图所示,在切换任务时,仍然是在Tick中进行的,在调用SysTick_Handler回调C函数时,已经完成了现场保护,在C函数中执行的是红色框中的代码。

任务切换时,说明在前面已经有任务在运行了,得到前面的任务,再调用get_next_task()得到要切换的新任务,然后判断这是否只有一个任务,如果新任务和前一个任务相同的话,就说明只有一个任务,此时直接返回就可以,现场保存和现场恢复等操作都发生在这个任务上。

不止一个任务时,使用set_task_stack()将前一个任务的栈继续保存到记录任务栈的数组中,因为前一个任务在运行过程中栈会变化,然后获取新任务的栈,再更新当前任务的下标cur_task,最后调用StartTask_asm来切换任务。

StartTask_asm中的操作和启动第一个任务时一样,也是进行软件恢复和硬件恢复,然后返回到新任务函数处执行,此时就完成了任务的切换。

如此一来,两个任务就可以交替执行了。

tu
SysTick定时器的超时时间设置成1ms,此时每隔1ms就会发生一次任务切换,两个任务交替执行。

Tick中断发生时,硬件会保存当前任务的R0~R3,R12,LR,返回地址,xPSR寄存器的值到当前任务的栈中,其中返回地址就是发生中断时那条指令的下一条地址。

当任务再次被切换回来以后,会将返回地址赋值给PC,该任务就会接着被切换走的位置继续执行。

  • 只有第一次启动任务和第一次被切换上来的任务,返回地址是任务函数的地址,任务从函数的起始位置执行。

图
如上图所示,在创建两个任务a和b时,传给任务函数的参数分别是a和b,运行起来后,可以看到串口中字符a和b在交替打印。

tu
如上图所示,再创建一个task_c任务来进行一些计算,并且打印计算结果。

tu
如上图所示,此时串口中打印出来的结果有字符a和b,还有计算任务计算的结果,三个任务在同时运行,也完美的实现了任务之间的切换。

🏀栈和寄存器变化

现在本喵已经自己实现了多任务之间的切换,下面来看看每个过程中栈和寄存器的变化。

🏐创建任务时

创建任务的时候,在create_task中伪造了现场:

tu
如上图所示,严格按照寄存器在栈中的存放顺序伪造好了现场,这个栈是我们在创建任务时给该任务指定的栈,也就是那个全局数组。

下面来看看,创建任务时,真正内存中的值和我们分析的是否一致:
图
如上图所示,打开该工程的反汇编文件,找到stack_a全局数组所在的地址是0x2000000c,本喵这里仅带大家看一个任务a的创建。

TU
如上图所示,首先要根据stack_a全局数组的地址计算出任务A栈的起始地址0x2000000C + 1024 = 0x2000040C,又因为伪造现场时栈中存放16个寄存器的值,所以0x2000040C + 16 * 4 = 0x200003CC得到就是伪造完现场后,任务A栈的SP所在位置。

SP所指位置在调试过程中查看该位置的内存,可以清楚的看到,从0x200003CC处开始向高地址增长的内存中,存放着我们要伪造的16个寄存器数据。

  • 伪造的R0处存放的值是0x00000061,这是任务A函数的形参,也就是字符a的ASCII码。
  • 伪造的返回地址处存放的值是0x0800054D,这是任务A要执行函数的入口地址。

tu
如上图所示,从反汇编文件中查找0x0800054C,发现这是task任务函数的入口地址。之所以在内存中存放的是0x0800054C + 1 = 0x0800054D,是因为最低位为1表示该函数使用的是Thumb指令集。

🏐任务启动时

tu

如上图所示,在SysTick_Handler_asm中断函数中打断点,让程序在启动任务时第一次进入Tick中断中完成软件保存后停下来。

从此时寄存器中的值可以看到SP = 0x2000FFBC,此时操作的栈就是这里,红色框中内存里的值是软件将对应寄存器中的值保存到栈中,蓝色框中是在产生Tick中断时,硬件将对应寄存器中的值保存到栈中。此时完成了现场保护

  • 此时的栈0x2000FFBC和我们指定的任务A的栈0x2000000C相差甚远,所以必然不是一个栈。

所以第一次启动任务过程中,Tick中断产生后,保存现场发生在程序启动时默认的栈中,不属于任何一个任务的栈。


图
如上图,在Tcik中断函数中完成现场保护以后会调用回调函数SysTick_Handler,在调回调函数中,获取到了任务A的栈stack = 0x200003CC,和我们前面计算出来的结果相符。

tu
如上图所示,回调函数的又调用StartTask_asm汇编函数来完成现场恢复,在调用汇编函数时,传入的参数stack = 0x200003CClr_bak = 特殊值

在汇编函数中,首先把任务A栈中伪造的R4~R11的值通过软件恢复到对应的寄存器中,此时R0寄存器指向任务A栈里伪造的R0处,硬件恢复就从这里开始。

  • 然后使用MSR MSP, R0将任务A的栈交给了SP寄存器管理。

此时我们伪造的任务A栈里的现场仅剩下R0~R3,R12,LR,返回地址,xPSR的值等待硬件恢复了。

tu

如上图所示,在StartTask_asm函数中完成软件恢复以后,用指令BX R1触发硬件恢复,此时R1中的值是由回调函数传递过来的特殊值。

硬件恢复完成以后,可以看到,程序从我们伪造的返回地址0x0800054D处开始执行,也就是任务A函数task,而且此时寄存器SP = 0x20000040C,该值是前面我们算出来的任务A栈的起始顶部位置。

  • 此时任务A在执行过程中,操作的就是属于该任务的栈了,完成了栈的切换。

🏐任务切换时

tu
如上图所示,在任务A启动以后执行的过程中,再次产生了Tick中断,打断点让程序停止在中断函数中。

在断点位置已经完成了现场保存,红色框中的部分是软件将LR,R4~R11寄存器中的值保存到栈中,蓝色框中的部分是硬件将R0~R3,R12,LR,返回地址,xPSR保存到栈中。

  • SP = 0x200003C4,该值位于任务A的栈0x2000000C~0x2000040C之间。

所以说现场保存发生在任务A的栈中,此时就保存了任务A的现场。

tu
如上图所示,中断函数再次调用了回调C函数SysTick_Handler,在该函数中,得到了任务B的栈stack = 0x200007CC,从内存窗口中可以看到任务B栈里存放的是创建任务B时伪造的现场。

  • 因为任务B是第一次被执行,所以恢复的是创建任务时伪造出来的现场。

可以看到,任务B栈中的返回地址也是0x0800054D,这是因为任务A和任务B执行的是一个任务函数。

tu
如上图,回调函数再次调用了StartTask_asm函数来完成任务B的现场恢复,在该函数中,软件先恢复任务B栈中R0~R11的值到对应寄存器中,然后将此时的R0 = 0x200007EC赋值给SP,等待硬件从这里开始恢复剩下的R0~R3,R12,LR,返回地址,xPSR

图

如上图,在StartTask_asm中使用BX R1触发硬件恢复以后,蓝色框中的R0~R3,R12,LR,返回地址,xPSR由硬件恢复到了对应的寄存器中,而且此时SP = 0x2000080C,这是任务B栈的栈顶位置。

  • 此后任务B在执行过程中使用的就是它自己的栈。

此时就恢复了任务B的现场,当再次产生Tick中断时,就会保存任务B的现场,恢复任务A的现场,如此往复就实现了两个任务之间的切换。

🏀总结

实时操作系统中最重要的就是多任务之间的切换,在这里我们自己动手实现了一遍,对任务的切换有了一个更深的认识。

要时刻记住,任务切换的本质就是栈的切换,任务创建的本质就是伪造现场

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/239576.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

数据可视化:解析跨行业普及之道

数据可视化作为一种强大的工具,在众多行业中得到了广泛的应用,其价值和优势不断被发掘和利用。今天就让我以这些年来可视化设计的经验,讨论一下数据可视化在各个行业中备受青睐的原因吧。 无论是商业、科学、医疗保健、金融还是教育领域&…

Vue2笔记

笔记 脚手架文件结构 ├── node_modules ├── public │ ├── favicon.ico: 页签图标 │ └── index.html: 主页面 ├── src │ ├── assets: 存放静态资源 │ │ └── logo.png │ │── component: 存放组件 │ │ └── HelloWorld.vue …

三天精通Selenium Web 自动化 - 如何找到元素

1. 什么是元素? 元素:HTML 元素 2. 定位方式解析 Selenium WebDriver 提供一个先进的技术来定位 web 页面元素。Selenium 功能丰富的API 提供了多个定位策略如:Name、ID、CSS 选择器、XPath 等等,如下图所示: 一般会用ID来定位…

Jmeter 测试 MQ 接口怎么做?跟我学秒变大神!

MQ(message queue)消息队列,是基础数据结构 先进先出 的一种典型数据结构。一般用来解决应用解耦,异步消息,流量削锋等问题,实现高性能,高可用,可伸缩和最终一致性架构。 MQ 主要产品包括:Rabb…

华清作业day45

头文件&#xff1a; #ifndef WIDGET_H #define WIDGET_H#include <QWidget> #include <QTime> #include <QTimer> #include <QTimerEvent> #include <QTextToSpeech> QT_BEGIN_NAMESPACE namespace Ui { class Widget; } QT_END_NAMESPACEclass…

Unity_ET框架项目-斗地主_启动运行流程

unity_ET框架项目-斗地主_启动运行流程 项目源码地址&#xff1a; Viagi/LandlordsCore: ET斗地主Demohttps://github.com/Viagi/LandlordsCore下载项目到本地。 启动运行步骤&#xff1a; 下载目录如下&#xff1a; 1. VS&#xff08;我用是2022版VisualStudio&#xff09…

2023年第十届GIAC全球互联网架构大会-核心PPT资料下载

一、峰会简介 谈到一个应用&#xff0c;我们首先考虑的是运行这个应用所需要的系统资源。其次&#xff0c;是关于应用自身的架构模式。最后&#xff0c;还需要从软件工程的不同角度来考虑应用的设计、开发、部署、运维等。架构设计对应用有着深远的影响&#xff0c;它的好坏决…

Facebook广告投放常见错误

在进行Facebook广告投放时&#xff0c;很容易犯一些常见的错误。这些错误可能导致广告投资的浪费&#xff0c;影响广告效果并降低回报。本文小编讲一些常见的Facebook广告投放错误&#xff0c;以及如何避免它们。 1、不明确目标受众 广告的成功与否很大程度上取决于你选择的目…

Vue 双向绑定:让数据与视图互动的魔法!(下)

&#x1f90d; 前端开发工程师&#xff08;主业&#xff09;、技术博主&#xff08;副业&#xff09;、已过CET6 &#x1f368; 阿珊和她的猫_CSDN个人主页 &#x1f560; 牛客高级专题作者、在牛客打造高质量专栏《前端面试必备》 &#x1f35a; 蓝桥云课签约作者、已在蓝桥云…

一天搞定jmeter入门到入职全套教程之Jmeter分布式测试

随着并发量的增大&#xff0c;一台机器就不能满足需求了&#xff0c;所以我们采用分布式&#xff08;Master-Slaver&#xff09;的方案去执行高并发的测试 注意事项&#xff1a; Master机器一般我们不执测试&#xff0c;所以可以拿一台配置差些的机器&#xff0c;主要用来采集…

Apollo配置发布原理解析

&#x1f4eb;作者简介&#xff1a;小明java问道之路&#xff0c;2022年度博客之星全国TOP3&#xff0c;专注于后端、中间件、计算机底层、架构设计演进与稳定性建设优化&#xff0c;文章内容兼具广度、深度、大厂技术方案&#xff0c;对待技术喜欢推理加验证&#xff0c;就职于…

C++ //习题2.5 请写出下列表达式的值。

C程序设计 &#xff08;第三版&#xff09; 谭浩强 习题2.5 习题2.5 请写出下列表达式的值。 (1) 3.5 * 3 2 * 7 - ‘a’ (2) 26 / 3 34 % 3 2.5 (3) 45 / 2 (int)3.14159 / 2 (4) a b (c a 6) 设a的初值为3 (5) a 3 * 5, a b 3 * 2 (6) (int)(a 6.5) % 2 …

波奇学Linux:Linux进程状态,进程优先级

编写一个程序模拟进程 查看进程状态 修改代码后发现进程状态为由S变成R R为运行态&#xff0c;S为阻塞态 第一次为S是因为调用了外设&#xff08;printf调用屏幕外设&#xff09;&#xff0c;实际上应该为R&#xff0c;S状态轮换&#xff0c;但是R太快了&#xff0c;所以每次…

使用docker编排容器

使用Dockerfile构建一个自定义的nginx 首先用docker拉一个nginx镜像 docker pull nginx拉取完成后&#xff0c;编辑一个Dockerfile文件 vim Dockerfile命令如下所示,FROM 后面跟的你的基础镜像&#xff0c;而run则是表示你构建镜像时需要执行的指令&#xff0c;下面的指令意…

python pip 相关缓存清理(windows+linux)

pip会大量缓存&#xff0c;如果全部堆在系统盘&#xff0c;会造成别的无法使用 windows和linux通用 一、linux linux是在命令行操作 1.查看缓存位置 pip cache dir我这里默认是在/root/.cache/pip 2.查看大小 du -sh /root/.cache/pip结果如下&#xff1a; 3.清理&#…

matlab 最小二乘拟合空间直线(方法三)

目录 一、算法原理1、算法过程2、参考文献二、代码实现三、结果展示四、相关链接博客长期更新,GPT与爬虫自重,你也未必能爬到最新版本。 一、算法原理 1、算法过程 空间直线的点向式方程为:

Node.js 事件循环简单介绍

1.简介 Node.js 事件循环是 Node.js 运行时环境中的一个核心机制&#xff0c;用于管理异步操作和回调函数的执行顺序。它基于事件驱动模型&#xff0c;通过事件循环来处理和派发事件&#xff0c;以及执行相应的回调函数。 Node.js 是单进程单线程应用程序&#xff0c;但是因为…

Spring boot注解

1.RestController RestController 注解用于标识一个类,表示该类的所有方法都返回JSON或XML响应&#xff0c;而不是视图页面。它是Controller和ResponseBody的组合 2.RequestMapping RequestMapping 注解用于映射HTTP请求到控制器方法或类。它可以用于类级别和方法级别,用于定…

制造业CRM选型指南:功能、价格与适用性

点击输入图片描述&#xff08;最多30字&#xff09; 在产业升级的大背景下&#xff0c;传统制造业数字化转型迫在眉睫。然而&#xff0c;生产制造业在转型过程中难免遇到难题&#xff0c;这时候就需要CRM客户管理系统的帮助。本文就将为您介绍&#xff0c;什么是制造业CRM&…

BearPi Std 板从入门到放弃 - 先天神魂篇(2)(RT-Thread LED PWM驱动)

简介 基于 BearPi Std 板从入门到放弃 - 先天神魂篇&#xff08;1&#xff09;(RT-Thread 指令点亮LED) 创建的项目, 添加PWM驱动LED的方式实现呼吸灯功能, 电路板及相关使用到的配件说明 开发板 &#xff1a; Bearpi Std(小熊派标准板) 主芯片: STM32L431RCT6 E53_ST1扩展板/…