RT-thread源码入门到入土 -(嵌入式提升篇)

本文旨在记录研究RT-thread的源码的过程。

Written by: Zhai Xiufeng

1. 概述

RT-thread是一个实时操作系统,也就是操作系统是以时间片和优先级共同来判定和分配CPU的执行资源。

2. 移植和搭建环境

2.1 RT系统源码和工具下载

源码下载地址:根据需求选择RT的系统版本即可。一般是选择某个大版本的最新版本。

GitHub - RT-Thread/rt-thread: RT-Thread is an open source IoT real-time operating system (RTOS).

ENV工具下载地址:

GitHub - RT-Thread/env: Python Scripts for RT-Thread/ENV

3. 系统挂载初始化

系统运行在MCU中,需要如同片上外设一般初始化,因为操作系统本质上是根据MCU的内核和片上外设的机制,来运行的一个功能。所以需要配置片上外设和基础变量初始化。

RT-Thread初始化接口的名称是rtthread_startup一般是执行在启动文件中_cmain中被执行。

初始化通过env建立新的工程时,会自动挂载,不太需要我们去管理。

4. 系统机制分析

实时操作系统(Real-Time Operating System,RTOS)具备一些关键功能,以满足实时应用的需求。以下是RTOS常见的功能:

任务管理:

多任务支持:允许多个任务并发运行。

任务优先级:任务可以分配不同的优先级,优先级高的任务会优先执行。

任务调度:实时调度器根据任务的优先级和时间限制,决定任务的执行顺序。

任务同步和通信:提供任务间同步和通信机制,如信号量、消息队列和事件标志。

中断管理:

快速中断响应:能够快速响应和处理硬件中断。

中断优先级:支持中断优先级设置,以确保关键中断优先处理。

内存管理:

静态和动态内存分配:支持静态和动态内存分配,确保内存使用的高效性和安全性。

内存保护:防止任务间的内存访问冲突,提高系统稳定性和安全性。

定时器和时钟管理:

精确定时:提供高精度的定时器,用于时间敏感的任务。

延时和超时功能:支持任务的延时和超时处理。

4.1 多任务支持

任务也就是当前执行业务代码,项目中任务会很多,比如显示、规约等等。在嵌入式中,任务也会被称作线程,一个进程有多个线程。根据嵌入式原理,业务代码是通过寄存器堆栈执行在内核中。但是由于一般MCU只存在一个内核和一些其他的限制,无法自动支持多任务运行,这个时候就需要软件辅助来促使多个任务在内核中被执行。

多任务支持(Multitasking Support)是实时操作系统(RTOS)和一般操作系统,使用一个核心让进程中能够在看似同时地运行多个任务。

多任务的支持内容的核心就是任务极短时间内完成切换,中间无法感知到线程执行的延时。

这其中有个特殊的中断(PendSV_Handler),该中断是多任务能够实行的必要条件。PendSV(可悬挂的系统服务调用,Pendable Service Call)是 ARM Cortex-M 系列处理器中的一种特殊中断。它设计用于操作系统的上下文切换,它是为了切换任务而专门设计的。PendSV 中断的优先级通常被设置为最低,这样它可以在其他更高优先级的中断处理完之后再执行。

原理如下:

保存当前任务上下文:

保存当前任务的寄存器状态(如程序计数器、堆栈指针、通用寄存器)。

将当前任务的上下文信息存储在任务控制块(管理和调度任务的关键数据结构)中。

选择下一个任务:

调度器从就绪队列中选择下一个最高优先级的任务。

更新当前任务指针以指向新任务的任务控制块。

恢复新任务上下文:

从新任务的任务控制块中恢复寄存器状态。

设置程序计数器指向新任务的执行地址。

恢复堆栈指针指向新任务的堆栈。

注意:当 PendSV_Handler 中的 BX LR 指令执行时,处理器会根据 PSP 恢复 R0-R3, R12, LR, PC, xPSR,这些寄存器的值是在新线程的堆栈中保存的。因此,恢复的新值是新线程的上下文。流程是当前线程执行指令时,中断来临,执行完当前的指令,LR保存当前线程的下一个指令,STM32自动压栈保存特殊寄存器的值。执行以下指令实现保存普通寄存器的值,最后执行BX LR,弹出PSP内的特殊寄存器值,跳回到另一个线程前面进入中断的原来LR压栈内容。

实现以上功能的代码如下:

; r0 --> switch from thread stack

; r1 --> switch to thread stack

; psr, pc, lr, r12, r3, r2, r1, r0 are pushed into [from] stack

        EXPORT PendSV_Handler

PendSV_Handler:

    ; 禁用中断以保护上下文切换

    MRS     r2, PRIMASK            ; 将当前中断状态(PRIMASK寄存器)保存到r2中

    CPSID   I                      ; 禁用中断,防止嵌套的PendSV处理

    ; 获取 rt_thread_switch_interrupt_flag

    LDR     r0, =rt_thread_switch_interrupt_flag  ; 加载线程切换中断标志的地址

    LDR     r1, [r0]               ; 加载线程切换中断标志的值到r1中

    CBZ     r1, pendsv_exit        ; 如果标志为0,表示PendSV已经处理,直接退出

    ; 将 rt_thread_switch_interrupt_flag 清零

    MOV     r1, #0x00              ; 将r1设为0

    STR     r1, [r0]               ; 将0存储到线程切换中断标志中

    LDR     r0, =rt_interrupt_from_thread  ; 加载"from"线程栈指针的地址

    LDR     r1, [r0]               ; 加载"from"线程栈指针到r1中

    CBZ     r1, switch_to_thread   ; 如果"from"线程栈指针为0,跳过寄存器保存步骤,直接切换到新线程

    MRS     r1, psp                ; 获取当前线程的栈指针(PSP)到r1中

#if defined ( __ARMVFP__ )

    TST     lr, #0x10              ; 测试EXC_RETURN[4]位是否设置

    BNE     skip_push_fpu          ; 如果设置了,跳过FPU寄存器保存

    VSTMDB  r1!, {d8 - d15}        ; 将FPU寄存器(s16 - s31)压入栈中

skip_push_fpu

#endif

    STMFD   r1!, {r4 - r11}        ; 将通用寄存器(r4 - r11)压入栈中用R1保存msp的地址

#if defined ( __ARMVFP__ )

    MOV     r4, #0x00              ; 初始化标志为0

    TST     lr, #0x10              ; 测试EXC_RETURN[4]位是否设置

    BNE     push_flag              ; 如果设置了,跳过设置标志

    MOV     r4, #0x01              ; 设置标志为1

push_flag

    ;STMFD   r1!, {r4}             ; 可选:将标志压入栈中(注释掉)

    SUB     r1, r1, #0x04          ; 调整栈指针以考虑标志的存储

    STR     r4, [r1]               ; 将标志存储到栈中

#endif

    LDR     r0, [r0]               ; 加载新的"from"线程栈指针

    STR     r1, [r0]               ; 更新"from"线程栈指针到内存中

switch_to_thread

    LDR     r1, =rt_interrupt_to_thread  ; 加载"to"线程栈指针的地址

    LDR     r1, [r1]               ; 加载"to"线程栈指针到r1中

    LDR     r1, [r1]               ; 从内存中加载实际的栈指针

#if defined ( __ARMVFP__ )

    LDMFD   r1!, {r3}              ; 从栈中弹出标志

#endif

    LDMFD   r1!, {r4 - r11}        ; 从栈中弹出通用寄存器(r4 - r11)

#if defined ( __ARMVFP__ )

    CBZ     r3, skip_pop_fpu       ; 如果标志为0,跳过FPU寄存器弹出

    VLDMIA  r1!, {d8 - d15}        ; 从栈中弹出FPU寄存器(s16 - s31)

skip_pop_fpu

#endif

    MSR     psp, r1                ; 使用新的栈指针(PSP)更新栈指针

#if defined ( __ARMVFP__ )

    ORR     lr, lr, #0x10          ; 设置LR中的FPCA(Floating-Point Context Active)位

    CBZ     r3, return_without_fpu ; 如果标志为0,跳过清除FPCA位

    BIC     lr, lr, #0x10          ; 清除LR中的FPCA位

return_without_fpu

#endif

pendsv_exit

    ; 恢复中断

    MSR     PRIMASK, r2            ; 从r2中恢复中断状态(重新启用中断)

ORR     lr, lr, #0x04          ; 设置LR中的返回处理器模式位以退出处理程序

BX      lr                     ; 跳转到LR中的地址(返回到先前中断的任务)

4.2任务优先级

RTOS任务优先级的概念
优先级数值:每个任务都被分配一个优先级,通常是一个整数值。数值越小,优先级越高。

调度策略:RTOS 通常使用优先级调度算法,高优先级的任务会抢占低优先级的任务。

优先级有两种:
抢占优先级:当该种任务变为就绪状态时,它可以抢占正在运行的低优先级任务,从而保证高优先级任务的及时执行。可以理解为该任务类似于中断任务,通常用来执行数据流的接收,比如(UART/I2C/RF)。
顺序优先级: 系统选择优先级最高的任务执行。并且会通过队列排队的方式来排队执行,保证低优先级的任务执行。如果有高优先级的任务就绪,这时候就会“插队”。

其中,实时操作系统中,线程切换通过触发 PendSV (可挂起的系统服务请求) 中断来实现。无论实现方式如何,核心思想是设置 PendSV 中断寄存器来手动唤醒该中断,完成线程切换。

4.2.1 抢占优先级

实现以上功能的代码如下:

  1. 首先通过第3章系统挂载初始化中的rt_hw_board_init初始化,对tick时钟进行初始化使能中断,间隔为1ms。

  1. 通过系统时钟每1ms中断中,通过手动拉pendsv中断的方式,通过4.1章节多任务支持的方式来切换调度。

4.2.2 顺序优先级

实现以上功能的代码如下:
1. 正常挂起线程:一般通过事件、消息、系统延时来挂起当前的线程,系统会自动将当前线程加载到suspend线程的列表中。

2. 正常切换调度:当正常挂起后,相关接口内都会调用该接口进行线程调度。主要功能是找到当前就绪态优先级最高的线程,并通过手动拉pendsv中断的方式,通过4.1章节多任务支持的方式来切换调度。

4.2.3 主动使能PendSV中断

    EXPORT rt_hw_context_switch_interrupt

    EXPORT rt_hw_context_switch

rt_hw_context_switch_interrupt:

rt_hw_context_switch:

    ; 将 rt_thread_switch_interrupt_flag 的地址加载到 r2

    LDR     r2, =rt_thread_switch_interrupt_flag

    ; 将 rt_thread_switch_interrupt_flag 的值加载到 r3

    LDR     r3, [r2]

    ; 将 r3 的值与 1 进行比较

    CMP     r3, #1

    ; 如果 r3 等于 1,跳转到 _reswitch

    BEQ     _reswitch

    ; 将 r3 设置为 1

    MOV     r3, #1

    ; 将 r3 的值存储到 rt_thread_switch_interrupt_flag 中

    STR     r3, [r2]

    ; 将 rt_interrupt_from_thread 的地址加载到 r2

    LDR     r2, =rt_interrupt_from_thread

    ; 将 r0 的值存储到 rt_interrupt_from_thread 中

    STR     r0, [r2]

_reswitch:

    ; 将 rt_interrupt_to_thread 的地址加载到 r2

    LDR     r2, =rt_interrupt_to_thread

    ; 将 r1 的值存储到 rt_interrupt_to_thread 中

    STR     r1, [r2]

    ; 将 NVIC 中断控制寄存器的地址加载到 r0

    LDR     r0, =NVIC_INT_CTRL

    ; 将 PendSV 设置位的值加载到 r1

    LDR     r1, =NVIC_PENDSVSET

    ; 将 r1 的值存储到 NVIC 中断控制寄存器中,从而触发 PendSV 异常

    STR     r1, [r0]

    ; 返回

    BX      LR

4.2.4 任务优先级机制

SysTick 中断:

优先级最高,用于系统时钟的递增和设置 PendSV 中断。主要的功能:1. 递增系统时钟 (tick++)  2. 设置 PendSV 中断。

PendSV 中断:

优先级最低,确保在所有高优先级中断处理完后才执行,用于线程切换。主要的功能就是保存现场(所有的运行寄存器),加载其他线程的执行现场。

中断处理流程:

高优先级的 SysTick 中断确保时钟准确性。嵌套中断发生时,SysTick 中断会中断低优先级中断,执行时钟递增并设置 PendSV。

低优先级的 PendSV 中断实现线程切换,保证系统的实时性和稳定性。PendSV 中断在所有高优先级中断处理完后才会执行,确保系统时钟准确并进行线程切换。

4.3 任务调度

任务调度包含优先级管理,多任务支持等。这其中的核心就是任务调度的算法和机制。

RT Thread提供的代码中,任务调度包含有SMP宏控的对称多核支持的功能,该功能主要是用来支持处理核心资源的功能。也就是在SMP的任务调度。这个在开发中不常用(常用AMP,该功能与平常开发差别不大,只是多了核间通信相关的功能,除此之外就类似于两个独立的APP。),所以我没有继续往下研究。

实现以上功能的代码如下:

代码较长切分为三段看。

1. 首先初始化程序需要的局部变量,并关闭中断。

2. 调度的机制和算法。首先获取就绪态下最高级别的线程。就绪态线程列表的线程由判断机制放入,比如:超时(os_delay),调度主动放弃(yield),事件/消息发送触发(event/msg send)。这其中有的包含移出suspend列表,放入就绪态列表,有的仅仅直接放入到就绪态列表。

3. 经过判断和确认最后需要跳转的线程,最终置位pendsv中断,进行任务调度跳转。

4.4 任务同步和通信

4.4.1 任务同步

任务同步主要用于协调多个任务的执行顺序,确保任务之间不会出现竞态条件或数据不一致的问题。常见的任务同步机制包括:

  1.  互斥锁(Mutex)

互斥锁用于保护共享资源,确保在同一时间只有一个任务可以访问该资源。

适用于防止多个任务同时修改共享数据,从而避免数据竞争和不一致。作用示意图如下。

以下mutex_take接口互斥锁主要原理是,对当期值进行对全局的变量值进行mutex->value判断,如果不为>0,则对全局的变量值进行mutex->value --;如果为零,则加载当前线程到suspend线程列表,并直接触发调度。

相对的,mutex_release则是恢复其他线程调用mutex_take而挂起在suspend列表的线程,若此时没有线程因mutex挂起,则mutex->value ++。

注意:其中还需要有个标志来判断哪个线程拥有这个锁的钥匙,最终用来开锁,因为只有上锁的线程才能解锁,并且不能让上锁的线程也因为再次上了次锁,导致线程切换调度,造成死锁。RT中使用的是mutex->hold就判处这个事情,同时也用来记录上锁的次数。当mutex->hold为0时,才会彻底解开锁。

  1.  信号量(Semaphore)

信号量可以是二进制信号量(Binary Semaphore)或计数信号量(Counting Semaphore)。

二进制信号量类似于互斥锁,但不仅限于保护资源,还可以用于任务之间的同步。计数信号量用于控制对资源的访问次数,适用于具有多个实例的资源。

其中用法跟互斥锁类似,保护的是有限的但不是仅有一个的资源。比如一片内存池,为了节约瞬时的ram使用率,将其分作了三个区域,但是有四个线程需要用这三个内存区域。那么此时就需要信号量,允许系统同时运行三个线程来处理,并阻塞一个线程暂时不做处理。

其中的原理也类似于互斥锁,作用示意图如下:

源码实现如下:

sem_take,则sem->value --,sem->value是初始化该信号量赋予的。

sem_release,就会进行sem->value --,代表资源被释放了一个。

  1.  事件标志组(Event Flags or Event Groups)

事件标志组用于任务之间的事件通知和同步。任务可以等待一个或多个事件标志被设置,以触发特定的操作。适用于需要等待多个条件满足后再执行任务的情况。

event_recv事件接收,在上文有所介绍,主要内容是当没事件时suspend当前线程,并切换调度。

event_send事件发送,则是通过事件找到接收事件对应的线程,并挂起当前线程,切换调度,跳转到接收事件对应线程。

  1.  消息队列(Message Queue)

消息队列以先进先出(FIFO)的方式存储和读取。该功能的机制与事件标志组(Event Flags or Event Groups)基本一样,事件用一个数值来表示,消息则是一个buffer,用于数据流的传输。消息队列本身是有大小的,前面的消息还没处理完,后续来的消息超出限制,RT Thread系统会直接扔掉后面来的消息。

消息接收mq_recv

消息发送mq_send

注意:发送消息后,是否直接跳转到对应的接收接口,每个操作系统可能存在差异。有的rtos会提供两套接口直接跳转或不直接跳转两类。

4.5 内存管理
4.5.1 堆管理

RT Thread本身提供三套内存管理的代码。可以通过rtconfig.h文件来决定使用哪个功能。直白的说,堆管理就是先将堆通过一个全局变量,固定申请过来,然后通过malloc/free来分配给线程使用。

rtconfig.h文件中,宏的代码如下。

以下是对应宏具体对应的功能。

RT_USING_MEMPOOL:

启用内存池功能。内存池是一种高效的内存管理方式,通过预分配和管理固定大小的内存块来减少内存分配和释放的开销。适用于那些频繁进行小块内存分配和释放的场景。

RT_USING_MEMHEAP:

启用内存堆功能。内存堆是一种动态内存管理方式,支持任意大小的内存分配和释放。内存堆适用于那些内存需求不确定且需要灵活管理的场景。

RT_MEMHEAP_FAST_MODE:

启用内存堆的快速模式。在快速模式下,内存堆的分配和释放操作会进行优化,以提高内存管理的效率。具体的优化措施可能包括减少碎片、加快查找空闲块的速度等。

RT_USING_MEMHEAP_AS_HEAP:

使用内存堆作为系统的默认堆。在启用该宏后,系统的默认内存分配和释放操作(如 malloc 和 free)将使用内存堆来管理内存。这意味着所有通过标准库函数进行的动态内存管理操作都会在内存堆上执行。

RT_USING_MEMHEAP_AUTO_BINDING:

启用内存堆的自动绑定功能。在多核系统中,不同的核心可以拥有各自独立的内存堆。启用该宏后,每个核心在启动时会自动绑定到一个特定的内存堆,以提高内存管理的效率和数据访问的局部性。

RT_USING_HEAP:

启用堆内存管理功能。启用该宏后,RT-Thread 将提供标准的堆内存管理接口(如 malloc、free、realloc 等),允许用户进行动态内存分配和释放。该功能通常依赖于底层的内存堆实现。

介绍堆之前,我们先了解下烧录文件的内容。因为我们需要确定管理的是什么,在程序中是什么作用。

(1)Code:代码段,存放程序的代码部分;

(2)RO-data:只读数据段,存放程序中定义的常量;

(3)RW-data:读写数据段,存放初始化为非 0 值的全局变量;

(4)ZI-data:0 数据段,存放未初始化的全局变量及初始化为 0 的变量;

首先我们知道代码段是存储在Flash中, 并且在执行中,不能被改变的。并且常量也是存储在Flash中,并会将这个常量存储在接口定义的代码段结尾下面地址中。程序在上电复位处理后,初始化阶段,对RW-data/ZI-data的数据进行导入,如下图。(这一块具体详情可以看下我的另一个文档-嵌入式入门到入土)

上图所示,左边区域为未上电的MCU,灰色区域是烧录文件,右边是上电后的初始化操作,将RW段和ZI段分别初始化在RAM内。剩余的则是堆,图中也标出了。这就是内存管理的动态内存堆。

了解我们要管理的区域,那么可以了解,堆管理的方式了,堆管理有三种方式。

1. 小内存管理算法

小内存管理算法是一个简单的内存分配算法。初始时,它是一块大的内存(下图)。

当需要分配内存块时,将从这个大的内存块上分割出相匹配的内存块,然后把分割出来的空闲内存块还回给堆管理系统中。每个内存块都包含一个管理用的数据头,通过这个头把使用块与空闲块用双向链表的方式链接起来。

rt_smem_alloc源码如下

从free的链表地址中(这个是标记的未被使用过块的最小地址,用来优化效率),取出当前块参数,判断是否满足初始化的大小。并且不断地扫描next大小,直到总堆的最大 - 需要申请的大小。因为free链表中是自小到大的绝对地址,如果到这个地址满足不了,就说明没有合适内存,无法申请。

rt_smem_free主要是标记当前内存为未被使用的,并且检查物理地址前后块是否未被使用过,如果是,则合并物理地址相连的块。

2. slab管理算法

RT-Thread 的 slab 分配器是在 DragonFly BSD 创始人 Matthew Dillon 实现的 slab 分配器基础上,针对嵌入式系统优化的内存分配算法。最原始的 slab 算法是 Jeff Bonwick 为 Solaris 操作系统而引入的一种高效内核内存分配算法。

RT-Thread 的 slab 分配器实现主要是去掉了其中的对象构造及析构过程,只保留了纯粹的缓冲型的内存池算法。slab 分配器会根据对象的大小分成多个区(zone),也可以看成每类对象有一个内存池,如下图所示:

如上图,拿空碗比作内存和盛饭比作申请内存举个例子,slab管理内存算法就是先弄好多不同大小不同个数的碗,进行编号。通过编号维护碗的位置。饭量不同的人来,要盛饭,此时就直接找到合适大小的碗直接就用。如果用完了,就放回去,通过zone的链表放置,不够了就在买一组碗。此时如果来了个大胃王,就走特殊通道,直接给他上个盆来盛饭。

这个算法优势比较明显,在申请的内存就是表格中内存块大小正好的时候,对RAM的利用率会高很多,因为没有小内存块的头和尾巴。

rt_slab_alloc源码如下,先计算得出用什么大小的内存合适,然后通过对应index偏移直接获取地址,标记z_nfree并移除已使用的地址。

rt_slab_free源码如下,将当前地址插入到free链表中,这样就完成了内存释放。

其中,需要注意,page中也维护了一个zone管理,如果空间page内的zone都为未使用状态,则也会在free接口中,将这个page free掉,并加载page到page空闲列表。

3. memheap管理算法

memheap 管理算法适用于系统含有多个地址可不连续的内存堆。使用 memheap 内存管理可以简化系统存在多个内存堆时的使用:当系统中存在多个内存堆的时候,用户只需要在系统初始化时将多个所需的 memheap 初始化,并开启 memheap 功能就可以很方便地把多个 memheap(地址可不连续)粘合起来用于系统的 heap 分配。

该算法类似于小内存管理算法,但是添加有不连续地址的管理功能。示意图如下。

rt_memheap_alloc源码首先从freelist取得合适大小的空闲块。并取出传入的size大小的空间,返回地址。

rt_memheap_free源码中,直接找到前后的item,根据是否使用,未使用的情况下合并成一个大的item。然后将该item插入到free列表中。

注意:memheap管理算法与小内存管理原理是一样的。代码的实现差异点是:每个不连续地址互相形成一个prv和next环形链表,合并时,直接取用。但prv_free与next_free组成一个整个内存管理对象的物理地址不连续free的一个链表。

4.6 定时器和时钟管理:
4.6.1 精确定时

提供高精度的定时器,用于时间敏感的任务。操作系统中,通常提供软件时钟接口,支持循环中断,和单次中断,通过触发回调的方式执行定时到达的任务。软件时钟是基于硬件时钟进行拓展的,时钟精度无法达到硬件时钟分频的ns的精度。

其作用除了提供给操作系统上层业务开发使用外,也为系统调度提供支持,系统机制中有大量的软件时钟运用,用来时间超时调度。

4.6.2 延时和超时功能

支持任务的延时和超时处理。主要是rt_thread_mdelay的接口,用以阻塞线程的延时。

注意:系统延时并不精确,延时时间只会等于大于设定的时间。因为该功能实现是依赖回调插入到就绪列表后面,并触发线程调度运行就绪列表中优先级最高的线程。

4.7 任务栈

系统机制不同,任务栈的处理方式也不同,在RT thread中,从内存管理我们知道,除了RW段/ZI段,其他的RAM空间都被分配做了系统内存管理池,被统一管理,那么栈也是被统一管理的。

RT_KERNEL_MALLOC的实现就是rt_malloc(sz)的实现,通过宏映射过来的。

首先,内存池管理,实际是通过全局申请除RW段/ZI段的全部RAM内存。编译中,我们知道全局变量是存放在堆中,也就是堆事向上使用的,小地址是起始地址。但是ARM的STM32中规定,stack是往下使用的,也就是大地址为起始地址。从在汇编指令中,sp++,实际是sp的值--,可以验证到这个说法。RT thread为了解决这个问题,会将这一区域倒过来使用,实际实现是将stack_addr+stack_size。

学习资料

RT thread新手指导

新手指导 (rt-thread.org)

RT thread简介和开发指导

RT-Thread 简介

RT thread API 参考手册

RT-Thread API参考手册: RT-Thread 简介

RT thread内核原理解析和实操手动实现

[野火]RT-Thread 内核实现与应用开发实战—基于STM32 — [野火]RT-Thread内核实现与应用开发实战——基于STM32 文档 (embedfire.com)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值