Toggle navigation
首页
问答
文章
积分商城
专家
专区
更多专区...
文档中心
返回主站
搜索
提问
会员
中心
登录
注册
源码分析
线程
线程的创建与销毁(源码分析)
发布于 2024-04-03 00:11:00 浏览:1773
订阅该版
# 线程的创建与销毁 [TOC] ## 线程的创建/初始化 在RTOS中,线程是系统运行的基础。我们今天来看看,在RTT中,一个线程是如何被创建/初始化,并且运行起来的。源码讲解得会比较多。 ### Creat `rt_thread_create()`是RTT动态创建出一个线程的函数。 ```c rt_thread_t rt_thread_create(const char *name, void (*entry)(void *parameter), void *parameter, rt_uint32_t stack_size, rt_uint8_t priority, rt_uint32_t tick) ``` 我们查看其所需要的参数: | 参数 | 意义 | | ------------------------------ | ---------------------------------------- | | `const char *name` | 线程的名字,使用一串字符串命名 | | `void (*entry)(void *parameter)` | 线程函数的入口,即这个线程所要运行的函数 | | `void *parameter` | 线程的参数 | | `rt_uint32_t stack_size` | 线程的堆栈大小 | | `rt_uint8_t priority` | 线程的优先级 | | `rt_uint32_t tick` | 线程的时间片 | 很多同学刚学习的时候可能跟我一样,平时见过的指针都是 `int *`,`char *` ,`double *` ,对 `void *` 比较陌生,不熟悉这是什么指针,有何作用? 这里找到一篇[文章](https://blog.csdn.net/yangbodong22011/article/details/53224856),对 `void *` 指针有一个初步大概的描述。 比较通俗的说一下就是:`char *`,`int *`,`void *` 指针本质上大小是一样的, 我们可以调用一下函数去输出一下`sizeof(int *)==sizeof(void *)`,我们会发现其会判定为真,这是因为在单片机指针大小都是确定的,其目的都是为了可以指向一个地址,例如Stm32的指针大小就是4字节,即32bit,这是因为Stm32的地址总线是32位的。但它们的区别是什么呢——**跳跃力**。我们在做一个小实验验证一下,a,b,c分别是8位,16位,32位的指针,然后然他们分别指向arr的首地址,再自加。 ```C int main(void) { rt_int8_t arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; rt_int8_t *a = arr; rt_int16_t *b = (rt_int16_t *)arr; rt_int32_t *c = (rt_int32_t *)arr; a++; b++; c++; if (sizeof(a) == sizeof(b) && sizeof(b) == sizeof(c)) rt_kprintf("*a: %d\n*b: %d\n*c: %d\n", *a, (rt_int8_t)*b, (rt_int8_t)*c); } ``` 最后输出的结果为: ```c *a: 2 *b: 3 *c: 5 ``` 结果正如我们所料,a b c 的大小是一样的。而且因为这是一个8位的一个数组,a自增应该跳单个字节,b自增应该跳两个字节,c自增应该跳三个字节。最后输出的时候记得输出一个8位的一个数据。 那 void * 所定义的指针就是未指定跳跃力的一个指针,常用在函数参数、函数返回值中需要兼容不同指针类型的地方。我们可以将别的类型的指针无需强制类型转换的赋值给`void *`类型。 例如在`void *memset(void *str, int c, size_t n)`此函数中的参数`void *str`,我们在使用的时候可以传进去一个int型的数组指针,也可以传进一个char型的数组指针去初始化数组。 解决了这个疑惑,相信各位在以后看代码的时候就会清晰很多。 说回正题上,我们进入到这个`rt_thread_create()`函数里看一下它是如何实现的。 ```c rt_thread_t rt_thread_create(const char *name, void (*entry)(void *parameter), void *parameter, rt_uint32_t stack_size, rt_uint8_t priority, rt_uint32_t tick) { struct rt_thread *thread; //先创建一个线程指针 void *stack_start; //定义一个线程堆栈的起始指针 thread = (struct rt_thread *)rt_object_allocate(RT_Object_Class_Thread, name); //创建一个名字为 name 的一个内核线程对象 if (thread == RT_NULL) return RT_NULL; stack_start = (void *)RT_KERNEL_MALLOC(stack_size); //系统分配堆栈 if (stack_start == RT_NULL) { /* allocate stack failure */ rt_object_delete((rt_object_t)thread); //如果堆栈分配失败的话,这个线程就创建不起来了 //需要把上面创建的线程对象给删除掉 return RT_NULL; } _thread_init(thread, name, entry, parameter, stack_start, stack_size, priority, tick); //初始化线程内容 return thread; } ``` ### Init `rt_thread_init()`是RTT静态初始化一个线程的函数。 ```c rt_err_t rt_thread_init(struct rt_thread *thread, const char *name, void (*entry)(void *parameter), void *parameter, void *stack_start, rt_uint32_t stack_size, rt_uint8_t priority, rt_uint32_t tick) ``` 它所需要的参数是: | 参数 | 意义 | | ------------------------------ | ---------------------------------------- | | `struct rt_thread *thread` | 线程的指针 | | `const char *name` | 线程的名字,使用一串字符串命名 | | `void (*entry)(void *parameter)` | 线程函数的入口,即这个线程所要运行的函数 | | `void *parameter` | 线程的参数 | | `void *stack_start` | 线程堆栈的起始指针 | | `rt_uint32_t stack_size` | 线程堆栈的大小 | | `rt_uint8_t priority` | 线程的优先级 | | `rt_uint32_t tick` | 线程的时间片 | 对比一下`rt_thread_creat()`所需要的参数,我们会发现多了一个线程的指针,线程堆栈的起始指针与大小。我们需要先手动定义一个`rt_thread Thread`线程对象实体,还有其所需要的堆栈,例如`rt_int8_t Thread_Stack[1024]`。其具体实现: ```c rt_err_t rt_thread_init(struct rt_thread *thread, const char *name, void (*entry)(void *parameter), void *parameter, void *stack_start, rt_uint32_t stack_size, rt_uint8_t priority, rt_uint32_t tick) { /* parameter check */ RT_ASSERT(thread != RT_NULL); RT_ASSERT(stack_start != RT_NULL); /* initialize thread object */ rt_object_init((rt_object_t)thread, RT_Object_Class_Thread, name); return _thread_init(thread, name, entry, parameter, stack_start, stack_size, priority, tick); } ``` 对比一下`rt_thread_creat()`里的实现,我们发现其两着最后的都是通过`_thread_init`完成的初始化操作的,不同的地方在于线程堆栈空间的分配问题。 ## 线程加入调度器中 我们创建好线程以后,再调用`rt_thread_startup()`将线程加入到调度中来。 ```c rt_err_t rt_thread_startup(rt_thread_t thread) { /* 参数检查 */ RT_ASSERT(thread != RT_NULL); RT_ASSERT((RT_SCHED_CTX(thread).stat & RT_THREAD_STAT_MASK) == RT_THREAD_INIT); RT_ASSERT(rt_object_get_type((rt_object_t)thread) == RT_Object_Class_Thread); LOG_D("startup a thread:%s with priority:%d", thread->parent.name, thread->current_priority); /* 计算线程的优先级,并把线程的状态改为挂起(suspend) */ rt_sched_thread_startup(thread); /* 恢复线程到就绪状态,等待调度 */ rt_thread_resume(thread); return RT_EOK; } ``` 线程启动的代码,我们会发现,线程创建完成后,并不是直接从初始化状态转变到就绪状态,而是先去挂起状态再转变为就绪状态。我的猜测是增加了系统稳定性和可预测性,将线程挂起后再转变为就绪状态可以提高系统的稳定性和可预测性。挂起状态意味着线程不会立即参与调度,直到调度器决定执行该线程。这样可以避免线程在启动过程中立即执行,可能导致系统资源争用或者不必要的上下文切换。 我之前还在论坛上看到比较有意思的[问题](https://blog.csdn.net/weixin_61583202/article/details/137138629?ops_request_misc=&request_id=&biz_id=102&utm_term=Rtt%E4%BB%BB%E5%8A%A1%E8%B0%83%E5%BA%A6%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-0-137138629.142^v100^control&spm=1018.2226.3001.4187),他比较了两个线程先后启动的问题,t2跟t3为同一优先级的线程,但是在源码中,我们先启动的是t2,然后再启动t3,但是结果却是t3先运行,t2后运行,查看了之后发现是头插法的思路,但是具体实现代码在哪里作者并没有讲,我来补充一下。 我们进入到`rt_thread_resume()`函数中,里面有一步操作是将线程从挂起状态转变为就绪状态。 ```c rt_err_t rt_thread_resume(rt_thread_t thread) { rt_sched_lock_level_t slvl; //定义调度锁级别指针,用于开关调度锁 rt_err_t error; /* 参数检查 */ RT_ASSERT(thread != RT_NULL); RT_ASSERT(rt_object_get_type((rt_object_t)thread) == RT_Object_Class_Thread); LOG_D("thread resume: %s", thread->parent.name); rt_sched_lock(&slvl); //关上调度锁 error = rt_sched_thread_ready(thread); //将线程从挂起状态转变为就绪状态 if (!error) //转变失败时 { error = rt_sched_unlock_n_resched(slvl); } else //打开调度锁 { rt_sched_unlock(slvl); } RT_OBJECT_HOOK_CALL(rt_thread_resume_hook, (thread)); return error; } ``` 我们进入到`rt_sched_thread_ready()`函数中看看,可以发现其最后35行中会调用`rt_sched_insert_thread()`,其目的就是讲线程插入到就绪状态的队列中。 ```c rt_err_t rt_sched_thread_ready(struct rt_thread *thread) { rt_err_t error; RT_SCHED_DEBUG_IS_LOCKED; if (!rt_sched_thread_is_suspended(thread)) { /* failed to proceed, and that's possibly due to a racing condition */ error = -RT_EINVAL; } else { if (RT_SCHED_CTX(thread).sched_flag_ttmr_set) { //首先处理超时定时器(如果已设置)。 //如果我们失败了,则不要继续,因为这很可能意味着一个超时中断服务程序在我们之前争抢恢复线程的执行权。 error = rt_sched_thread_timer_stop(thread); } else { error = RT_EOK; } if (!error) { //把线程从挂起队列中移除 rt_list_remove(&RT_THREAD_LIST_NODE(thread)); #ifdef RT_USING_SMART thread->wakeup_handle.func = RT_NULL; #endif //将队列插入到就绪队列中 rt_sched_insert_thread(thread); } } return error; } ``` ```C void rt_sched_insert_thread(struct rt_thread *thread) { rt_base_t level; RT_ASSERT(thread != RT_NULL); /* disable interrupt */ level = rt_hw_interrupt_disable(); /* it's current thread, it should be RUNNING thread */ if (thread == rt_current_thread) { RT_SCHED_CTX(thread).stat = RT_THREAD_RUNNING | (RT_SCHED_CTX(thread).stat & ~RT_THREAD_STAT_MASK); goto __exit; } /* 将线程的状态改为READY */ RT_SCHED_CTX(thread).stat = RT_THREAD_READY | (RT_SCHED_CTX(thread).stat & ~RT_THREAD_STAT_MASK); //如果此时线程处于YIELD状态(主动让出或者时间片用完) //把它插入到相同优先级菜单的头节点的前面(其实就是就绪队列的末尾,因为这是一条双向链表) if((RT_SCHED_CTX(thread).stat & RT_THREAD_STAT_YIELD_MASK) != 0) { rt_list_insert_before(&(rt_thread_priority_table[RT_SCHED_PRIV(thread).current_priority]), &RT_THREAD_LIST_NODE(thread)); } //时间片没用完的就把它插入到头节点的后面(其实就绪队列的首位) else { rt_list_insert_after(&(rt_thread_priority_table[RT_SCHED_PRIV(thread).current_priority]), &RT_THREAD_LIST_NODE(thread)); } LOG_D("insert thread[%.*s], the priority: %d", RT_NAME_MAX, thread->parent.name, RT_SCHED_PRIV(rt_current_thread).current_priority); /* set priority mask */ #if RT_THREAD_PRIORITY_MAX > 32 rt_thread_ready_table[RT_SCHED_PRIV(thread).number] |= RT_SCHED_PRIV(thread).high_mask; #endif /* RT_THREAD_PRIORITY_MAX > 32 */ rt_thread_ready_priority_group |= RT_SCHED_PRIV(thread).number_mask; __exit: /* enable interrupt */ rt_hw_interrupt_enable(level); } ``` 我们可以看到,在代码17-31行是有关线程插入的一个实现代码,因为管理线程队列的是一个双向链表,我们可以根据情况在头节点前后位置插入就行。大致情况如下: > 优先级 > > 0:头节点 > > 1:头节点 > > 2:头节点 > > 3:头节点 > > ... > > 31:头节点 当我们插入一个新的优先级为2的线程A时,它会被插在头节点的后面: > 优先级 > > 0:头节点 > > 1:头节点 > > 2:头节点<->线程A(指回头节点) > > 3:头节点 > > ... > > 31:头节点 此时再插入一个新的优先级为2的线程B时,它也会被插在头节点的后面: > 优先级 > > 0:头节点 > > 1:头节点 > > 2:头节点<->线程B<->线程A(指回头节点) > > 3:头节点 > > ... > > 31:头节点 这就解释了上文中提到的t3先运行,t2后运行的问题,那这时再插入一个状态为YEILD的线程C: > 优先级 > > 0:头节点 > > 1:头节点 > > 2:线程C<->头节点<->线程B<->线程A(指回线程C) > > 3:头节点 > > ... > > 31:头节点 实际上跟下面这种情况时一样的,因为调度器在调度的时候,会取头节点的NEXT来进行调度,下面的代码中也体现出来: > 优先级 > > 0:头节点 > > 1:头节点 > > 2:头节点<->线程B<->线程A<->线程C(指回头节点) > > 3:头节点 > > ... > > 31:头节点 16行,在获取最高优先线程的过程中我们发现获取的是`rt_thread_priority_table[highest_ready_priority].next`即头节点的下一位,即这个才是能够第一得到运行的线程。到这就解决那个问题啦。 ```c static struct rt_thread* _scheduler_get_highest_priority_thread(rt_ubase_t *highest_prio) { struct rt_thread *highest_priority_thread; rt_ubase_t highest_ready_priority, local_highest_ready_priority; struct rt_cpu* pcpu = rt_cpu_self(); highest_ready_priority = _get_global_highest_ready_prio(); local_highest_ready_priority = _get_local_highest_ready_prio(pcpu); /* get highest ready priority thread */ if (highest_ready_priority < local_highest_ready_priority) { *highest_prio = highest_ready_priority; highest_priority_thread = RT_THREAD_LIST_NODE_ENTRY( rt_thread_priority_table[highest_ready_priority].next); } else { *highest_prio = local_highest_ready_priority; if (local_highest_ready_priority != -1) { highest_priority_thread = RT_THREAD_LIST_NODE_ENTRY( pcpu->priority_table[local_highest_ready_priority].next); } else { highest_priority_thread = RT_NULL; } } RT_ASSERT(!highest_priority_thread || rt_object_get_type(&highest_priority_thread->parent) == RT_Object_Class_Thread); return highest_priority_thread; } ``` ## 线程的删除 在RTT中,他提供了两种方式删除线程,分别是 `rt_thread_delete()`与 `rt_thread_detech()`,分别能够对应上`rt_thread_create()`动态创建的线程与`rt_thread_init()`静态初始化的线程。 ```c rt_err_t rt_thread_delete(rt_thread_t thread) { /* parameter check */ RT_ASSERT(thread != RT_NULL); RT_ASSERT(rt_object_get_type((rt_object_t)thread) == RT_Object_Class_Thread); RT_ASSERT(rt_object_is_systemobject((rt_object_t)thread) == RT_FALSE); return _thread_detach(thread); } ``` ```c rt_err_t rt_thread_detach(rt_thread_t thread) { /* parameter check */ RT_ASSERT(thread != RT_NULL); RT_ASSERT(rt_object_get_type((rt_object_t)thread) == RT_Object_Class_Thread); RT_ASSERT(rt_object_is_systemobject((rt_object_t)thread)); return _thread_detach(thread); } ``` 对比一下,除了第6行中进行的参数检查的结果不同外,其他的都一致,最后调用的是`_thread_detach(thread)`函数。 论坛上也有一篇[文章](https://club.rt-thread.org/ask/question/03b6929d86c1ea73.html),情况是创建线程一段时间后再删除线程,结果在msh命令中使用ps查看线程信息的时候,才发现只是把线程的状态改为了CLOSE,并没有真的把线程剔除出线程队列。我们通过代码来看看为什么会产生这种结果。 我们进入到`_thread_detach()`,可以看到线程先从就绪队列中移除,然后再转为CLOSE状态,以及做一些后续清理操作,但是线程堆栈的脱离与释放还没有做,也就是线程还挂在相应的`rt_object_information`上,所以才会出现上文提到的情况。 ```C static rt_err_t _thread_detach(rt_thread_t thread) { rt_err_t error; rt_sched_lock_level_t slvl; rt_uint8_t thread_status; rt_base_t critical_level; //开启调度锁 critical_level = rt_enter_critical(); //打开中断锁 rt_sched_lock(&slvl); //获取当前线程状态 thread_status = rt_sched_thread_get_stat(thread); if (thread_status != RT_THREAD_CLOSE)//判断线程是否已经是CLOSE状态了 { if (thread_status != RT_THREAD_INIT) { //将线程从就绪队列中移除 rt_sched_remove_thread(thread); } //释放定时器 rt_timer_detach(&(thread->thread_timer)); //将线程的状态转为CLOSE rt_sched_thread_close(thread); //开启中断锁 rt_sched_unlock(slvl); //释放信号量 _thread_detach_from_mutex(thread); //插入僵尸队列 rt_thread_defunct_enqueue(thread); error = RT_EOK; } else { rt_sched_unlock(slvl); /* already closed */ error = RT_EOK; } rt_exit_critical_safe(critical_level); return error; } ``` 那么线程真正完全被删除是在哪里呢?—————空闲线程里。 我们在idle.c中找一下相应的代码。 ```c static void idle_thread_entry(void *parameter) { RT_UNUSED(parameter); #ifdef RT_USING_SMP if (rt_hw_cpu_id() != 0) { while (1) { rt_hw_secondary_cpu_idle_exec(); } } #endif /* RT_USING_SMP */ while (1) { #ifdef RT_USING_IDLE_HOOK rt_size_t i; void (*idle_hook)(void); for (i = 0; i < RT_IDLE_HOOK_LIST_SIZE; i++) { idle_hook = idle_hook_list[i]; if (idle_hook != RT_NULL) { idle_hook(); } } #endif /* RT_USING_IDLE_HOOK */ #ifndef RT_USING_SMP rt_defunct_execute(); #endif /* RT_USING_SMP */ #ifdef RT_USING_PM void rt_system_power_manager(void); rt_system_power_manager(); #endif /* RT_USING_PM */ } } ``` 31行,我们看到一个僵尸线程处理的函数`rt_defunct_execute()`,然后进入其中。 ```c static void rt_defunct_execute(void) { /* Loop until there is no dead thread. So one call to rt_defunct_execute * will do all the cleanups. */ while (1) { rt_thread_t thread; rt_bool_t object_is_systemobject; void (*cleanup)(struct rt_thread *tid); #ifdef RT_USING_MODULE struct rt_dlmodule *module = RT_NULL; #endif /* get defunct thread */ thread = rt_thread_defunct_dequeue(); if (thread == RT_NULL) { break; } #ifdef RT_USING_MODULE module = (struct rt_dlmodule*)thread->parent.module_id; if (module) { dlmodule_destroy(module); } #endif #ifdef RT_USING_SIGNALS rt_thread_free_sig(thread); #endif /* store the point of "thread->cleanup" avoid to lose */ cleanup = thread->cleanup; /* if it's a system object, not delete it */ object_is_systemobject = rt_object_is_systemobject((rt_object_t)thread); if (object_is_systemobject == RT_TRUE) { /* detach this object */ rt_object_detach((rt_object_t)thread); } /* invoke thread cleanup */ if (cleanup != RT_NULL) { cleanup(thread); } #ifdef RT_USING_HEAP #ifdef RT_USING_MEM_PROTECTION if (thread->mem_regions != RT_NULL) { RT_KERNEL_FREE(thread->mem_regions); } #endif /* if need free, delete it */ if (object_is_systemobject == RT_FALSE) { /* release thread's stack */ #ifdef RT_USING_HW_STACK_GUARD RT_KERNEL_FREE(thread->stack_buf); #else RT_KERNEL_FREE(thread->stack_addr); #endif /* delete thread object */ rt_object_delete((rt_object_t)thread); } #endif } } ``` 38行、58行,跟上面的rt_thread_delete()、rt_thread_detach()里面参数检查的内容呼应上了,我们可以看到静态线程跟动态线程的处理方式不一样,动态线程的处理中需要释放掉内存的空间`RT_KERNEL_FREE(thread->stack_addr)`。 ```c void rt_object_detach(rt_object_t object) { rt_base_t level; struct rt_object_information *information; /* object check */ RT_ASSERT(object != RT_NULL); RT_OBJECT_HOOK_CALL(rt_object_detach_hook, (object)); information = rt_object_get_information((enum rt_object_class_type)object->type); RT_ASSERT(information != RT_NULL); level = rt_spin_lock_irqsave(&(information->spinlock)); /* remove from old list */ rt_list_remove(&(object->list)); rt_spin_unlock_irqrestore(&(information->spinlock), level); object->type = 0; } ``` ```c void rt_object_delete(rt_object_t object) { rt_base_t level; struct rt_object_information *information; /* object check */ RT_ASSERT(object != RT_NULL); RT_ASSERT(!(object->type & RT_Object_Class_Static)); RT_OBJECT_HOOK_CALL(rt_object_detach_hook, (object)); information = rt_object_get_information((enum rt_object_class_type)object->type); RT_ASSERT(information != RT_NULL); level = rt_spin_lock_irqsave(&(information->spinlock)); /* remove from old list */ rt_list_remove(&(object->list)); rt_spin_unlock_irqrestore(&(information->spinlock), level); /* reset object type */ object->type = RT_Object_Class_Null; /* free the memory of object */ RT_KERNEL_FREE(object); } ``` 在两个函数中,都会有一个`rt_list_remove()`的操作,这个就是将线程从`rt_object_information`中脱离出来。在动态线程中,我们还需要把线程对象给FREE掉,把内存空间释放出来。 上面提到的文章中,因为他在删除最后一个线程的时候,即rt_thread_delete(tid[4]),没有进入到一个空闲线程中而是直接while(1),导致这个线程一直在空转,无法进入到空闲线程中完全剔除此线程,所以会留下一个creat线程。前面创建的creat线程因为其后面都会跟一个rt_thread_delay函数,在这个函数中会让系统进入到空闲线程中。所以前面四个线程都被完全剔除了,在msh命令调出来的线程列表中也没有看到。 ## 总结 在此篇文章中,粗略的浅谈了一下在RTT下,线程的一些状态的变化,有什么不正确的地方请大家多多指点。
4
条评论
默认排序
按发布时间排序
登录
注册新账号
关于作者
Zerro
这家伙很懒,什么也没写!
文章
3
回答
3
被采纳
1
关注TA
发私信
相关文章
1
请问执行rt_thread_delete的操作后,线程还在运行是什么情况?
2
rtthread中,线程中的ADC采样率需求比时钟嘀嗒需求高怎么办?
3
有没有检测系统中有没有某个名字的线程的接口函数?
4
使用finsh 进行ota成功,线程里开ota失败
5
调度锁会引起线程内存不足
6
线程处于close状态消耗资源吗
7
paho_mqtt线程相关疑问
8
thread中不能使用rt_timer_start() 来开启定时器
9
线程递归是什么形成的?
10
程序运行一会儿报错,拜托拜托
推荐文章
1
RT-Thread应用项目汇总
2
玩转RT-Thread系列教程
3
国产MCU移植系列教程汇总,欢迎查看!
4
机器人操作系统 (ROS2) 和 RT-Thread 通信
5
五分钟玩转RT-Thread新社区
6
【技术三千问】之《玩转ART-Pi》,看这篇就够了!干货汇总
7
关于STM32H7开发板上使用SDIO接口驱动SD卡挂载文件系统的问题总结
8
STM32的“GPU”——DMA2D实例详解
9
RT-Thread隐藏的宝藏之completion
10
【ART-PI】RT-Thread 开启RTC 与 Alarm组件
热门标签
RT-Thread Studio
串口
Env
LWIP
SPI
AT
Bootloader
Hardfault
CAN总线
FinSH
ART-Pi
USB
DMA
文件系统
RT-Thread
SCons
RT-Thread Nano
线程
MQTT
STM32
RTC
FAL
rt-smart
ESP8266
I2C_IIC
WIZnet_W5500
UART
ota在线升级
PWM
cubemx
freemodbus
flash
packages_软件包
BSP
潘多拉开发板_Pandora
定时器
ADC
GD32
flashDB
socket
中断
Debug
编译报错
msh
SFUD
keil_MDK
rt_mq_消息队列_msg_queue
ulog
C++_cpp
at_device
本月问答贡献
踩姑娘的小蘑菇
7
个答案
3
次被采纳
a1012112796
13
个答案
2
次被采纳
张世争
9
个答案
2
次被采纳
rv666
5
个答案
2
次被采纳
用户名由3_15位
11
个答案
1
次被采纳
本月文章贡献
程序员阿伟
9
篇文章
2
次点赞
hhart
3
篇文章
4
次点赞
大龄码农
1
篇文章
5
次点赞
RTT_逍遥
1
篇文章
2
次点赞
ThinkCode
1
篇文章
1
次点赞
回到
顶部
发布
问题
投诉
建议
回到
底部