Toggle navigation
首页
问答
文章
积分商城
专家
专区
更多专区...
文档中心
返回主站
搜索
提问
会员
中心
登录
注册
内核
嵌入式操作系统
等待队列之我与托尼老师
发布于 2024-04-15 10:10:21 浏览:353
订阅该版
[TOC] ## 简介 等待队列(waitqueue)是操作系统中用于管理等待资源线程的一种机制,是实现线程通信和同步资源访问的底层技术。 但是,等待队列和信号量有什么不同呢?我认为等待队列可以看作是信号量的增强版。 在等待单个资源的时候,等待队列和信号量的用法几乎是相同的。例如,串口数据未到达时,可以使用sem_take/wqueue_wait去等待资源,当串口数据到来时通过接收回调函数执行sem_release/wqueue_wakeup来唤醒线程。 而等待队列还拥有一个信号量没有特性:加入等待队列而不阻塞。这就意味着我们可以让线程加入多个资源的等待队列,一次性监视多个资源。 --- **位置:** `#include
` ## 内部结构 在研究等待队列的创建之前,我们首先需要了解等待队列由哪些结构体组成,以及这些结构体之间的联系。通过下图,我们可以清晰地看到等待队列分为两部分:队头节点和队员节点。队头节点和队员节点之间通过 list 节点相互连接,形成循环双向链表,而每个队员节点又记录了它们当前的队头节点。 我们可以根据队头节点和队员节点之间的关系得出以下两个结论: - 等待队列维护了一个循环双向链表。 - 在这种结构中,我们每次取出节点都是从队头节点的后一个节点开始取。 - 当我们将节点插入到队头节点前面时(使用 insert_before 操作),实际上是将节点插入到队尾。 - 当我们将节点插入到队头节点后面时(使用 insert_after 操作),实际上是将节点插入到队首。 - 每个等待节点都能够通过成员 wqueue 找到队头节点。 ![screenshot_image.png](https://oss-club.rt-thread.org/uploads/20240415/886d02ec20315527afad705a51cfa883.png.webp) 了解了等待队列的组成和关系之后,让我们进一步了解队头节点和队员节点的结构体成员: **rt_wqueue_t:** | 成员 | 描述 | | --- | --- | | rt_uint32_t **flag** | 标记了队列的状态(唤醒/可用) | | rt_list_t **waiting_list** | 等待队列的队头节点 | | struct rt_spinlock **spinlock** | 等待队列的同步锁(不需要太关注) | **rt_wqueue_node:** | 成员 | 描述 | | --- | --- | | rt_thread_t **polling_thread** | 线程句柄 | | rt_list_t **list** | 等待队列的队员节点 | | rt_wqueue_t **_wqueue_ | 等待队列头指针 | | rt_wqueue_func_t **wakeup** | 唤醒线程前调用的函数 | | rt_uint32_t **key** | 节点私有的数据 -- 作用可以自行定义 | ## 创建队列头 简单了解结构体成员之后,我们可以先创建一个等待队列来进一步学习。 **创建等待队列头:** ```c rt_inline void rt_wqueue_init(rt_wqueue_t *queue) { RT_ASSERT(queue != RT_NULL); queue->flag = RT_WQ_FLAG_CLEAN; // 将队列设置为可插入状态 rt_list_init(&(queue->waiting_list)); // 初始化链表节点 rt_spin_lock_init(&(queue->spinlock)); // 初始化同步锁 } ``` rt_wqueue_init函数的主要作用是将链表节点waiting_list初始化为可用状态,并将flag赋值为RT_WQ_FLAG_CLEAN,表示队列可用且可插入,最后初始化锁spinlock于同步等待队列。初始化完成后,我们就可以开始向队列插入节点了。 **实例:** ```c // 创建并初始化队列 rt_wqueue_t queue; rt_wqueue_init(&queue); ``` ## 加入队列 这部分是等待队列中最重要的部分,毕竟我们所做的一切工作都是为了能够将线程添加到等待队列。 线程加入队列有两种方式: - 阻塞式加入:这种方式类似于信号量的用法,当参数condition为 0 时,线程会被阻塞,并将含有线程信息的等待节点加入到等待队列中。 ```c int rt_wqueue_wait(rt_wqueue_t *queue, int condition, int msec) ``` - 非阻塞式加入:这种方式是等待队列的一个特性,它允许将等待节点添加到等待队列中,而不会阻塞线程。但需要注意的是,这种方式需要我们自己创建并初始化等待节点。 ```c void rt_wqueue_add(rt_wqueue_t *queue, struct rt_wqueue_node *node) ``` ### 阻塞式加入 它有三种阻塞加入方式: ```c // 不可中断的调用 -- 意味着线程在等待时不接受任何信号,只有唤醒函数或超时才能将其唤醒。 int rt_wqueue_wait(rt_wqueue_t *queue, int condition, int timeout); // 可中断的调用 -- 可接收任何非KILLABLE的信号以中断阻塞并唤醒线程。 int rt_wqueue_wait_interruptible(rt_wqueue_t *queue, int condition, int timeout); // 可中断的调用 -- 可接受任何信号以中断阻塞并唤醒线程。 int rt_wqueue_wait_killable(rt_wqueue_t *queue, int condition, int timeout); ``` 它们的参数和参数的作用都是相同的: | 参数 | 作用 | | --- | --- | | queue | 当前线程要加入的队列。 | | condition | 条件判断的结果,当判断结果为0时,线程将被加入等待队列并阻塞,否则直接退出。 | | timeout | 超时时间的单位是毫秒。传入参数1000表示超时时间为1秒,当超时时间为0时,函数会直接退出。这个参数用于控制线程在等待过程中的最大等待时间。 | 这三个函数底层都调用了 **_rt_wqueue_wait** 函数,只是传入了不同的suspend_flag参数,用于指定线程阻塞的类型。 ```c static int _rt_wqueue_wait(rt_wqueue_t *queue, int condition, int msec, int suspend_flag); ``` 函数调用关系如下: ![screenshot_image.png](https://oss-club.rt-thread.org/uploads/20240415/3704e346335aa43ca250d02cb7f51601.png) 我们可以进入_rt_wqueue_wait函数它干了什么: ```c static int _rt_wqueue_wait(rt_wqueue_t *queue, int condition, int msec, int suspend_flag) { int tick; rt_thread_t tid = rt_thread_self(); // 当前线程的句柄 rt_timer_t tmr = &(tid->thread_timer); // 当前线程的定时器 rt_base_t level; rt_err_t ret; struct rt_wqueue_node __wait; // 等待节点 /* current context checking */ RT_DEBUG_SCHEDULER_AVAILABLE(RT_TRUE); tick = rt_tick_from_millisecond(msec); // 将等待时间的单位从ms转换为tick if ((condition) || (tick == 0)) // 条件满足或超时时间为0直接退出 return 0; // 初始化等待节点 __wait.polling_thread = rt_thread_self(); __wait.key = 0; __wait.wakeup = __wqueue_default_wake; // 使用默认线程唤醒回调函数,该函数只执行return 0 __wait.wqueue = queue; rt_list_init(&__wait.list); // 进入临界区 level = rt_spin_lock_irqsave(&(queue->spinlock)); /* reset thread error */ tid->error = RT_EOK; // 当该队列执行了唤醒函数,但是唤醒的线程还没得到调用时,不插入新节点 if (queue->flag == RT_WQ_FLAG_WAKEUP) { /* already wakeup */ goto __exit_wakeup; } // 将线程挂起 ret = rt_thread_suspend_with_flag(tid, suspend_flag); if (ret != RT_EOK) { rt_spin_unlock_irqrestore(&(queue->spinlock), level); /* suspend failed */ return -RT_EINTR; } // 将节点插入到队列的末尾 rt_list_insert_before(&(queue->waiting_list), &(__wait.list)); /* start timer */ if (tick != RT_WAITING_FOREVER) { rt_timer_control(tmr, RT_TIMER_CTRL_SET_TIME, &tick); rt_timer_start(tmr); } rt_spin_unlock_irqrestore(&(queue->spinlock), level); // 退出临界区 rt_schedule(); // 开启调度 // 当函数执行到这里,那就意味着线程被唤醒并运行了。 level = rt_spin_lock_irqsave(&(queue->spinlock)); // 恢复队列的可插入状态,并将节点从队列中删除 __exit_wakeup: queue->flag = RT_WQ_FLAG_CLEAN; rt_spin_unlock_irqrestore(&(queue->spinlock), level); rt_wqueue_remove(&__wait); return tid->error > 0 ? -tid->error : tid->error; } ``` 代码看着很多,我们也逐行给了注释,但是是在不想看也没关系,我们对它完成的工作做一个总结: 1. 初始化等待节点 2. **挂起线程** 3. **将等待节点加入等待队列** 4. 设置等待超时时间 5. 进行调度 6. 唤醒后线程后进行清理 ### 非阻塞式加入 非阻塞式的加入仅仅指线程在加入等待队列时不阻塞。因为线程在运行状态时唤醒函数是无效的,所以线程在加入多个等待队列后通常会挂起,这样才能保证线程能够及时接收到资源的唤醒通知。 当线程x加入多个资源等待队列时的样子大概是这样子的: ![screenshot_image.png](https://oss-club.rt-thread.org/uploads/20240415/1bf12373b9cb457a16cae431fca33b81.png) **官方提供了两种方式创建等待节点:** ```c // 使用自定义的函数作为唤醒线程的回调函数 // **注意**:使用自定义唤醒函数时,函数的正常返回值必须得是0,否则无法正常唤醒线程。 #define DEFINE_WAIT_FUNC(name, function) \ struct rt_wqueue_node name = { \ rt_current_thread, \ 更改为:rt_thread_self(), RT_LIST_OBJECT_INIT(((name).list)), \ function, \ 在functiong前添加NULL, 0 \ } // 使用默认唤醒回调函数 #define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, __wqueue_default_wake) ``` 自定义唤醒回调函数的示例: ```c // 自定义函数 int rt_wqueue_func(struct rt_wqueue_node *wait, void *key) { rt_kprintf("key = %d\n", *key); return 0; } // 使用自定义的唤醒函数初始化等待节点B DEFINE_WAIT_FUNC(B, rt_wqueue_func); ``` 观察定义我们发现完成等待节点的创建和初始化工作的是个宏,这个宏创建了变量名为name,唤醒函数为function的等待节点,在创建的同时也完成了成员的赋值: - 线程句柄:当前线程 - 链表节点初始化 -- 将前后指针都指向自己 - 将唤醒函数赋值为传入的function - key赋值为0 除了使用官方提供的宏以外我们也可以自己编写函数对等待节点进行初始化。 **加入队列** 创建好等待节点之后就可以将其加入对应的等待队列了 ```c void rt_wqueue_add(rt_wqueue_t *queue, struct rt_wqueue_node *node) { ...... node->wqueue = queue; // 记录加入的队列头指针 rt_list_insert_before(&(queue->waiting_list), &(node->list)); // 插入到队尾 ...... } ``` 对于该函数我们只需要注意一个点: 这意味着节点插入的方式是前插,即新节点会插入到队列的尾部,因此等待队列会按照先进先出的顺序进行唤醒。 ## 删除节点 删除节点只需要将等待节点指针作为参数传递,内部实际就是调用了链表节点的删除函数。 ```c void rt_wqueue_remove(struct rt_wqueue_node *node) { ...... rt_list_remove(&(node->list)); ...... } ``` ## 唤醒函数 在讲解其调用方法还有步骤前,我们先总结一下它完成了哪些工作,方便我们在后续理解代码。 1. 将队列状态更改为RT_WQ_FLAG_WAKEUP 2. 将队列中第一个可用的等待节点唤醒 3. 执行调度 有了这些了解之后我们带着目的去看源码应该就简单多了(省略了锁的使用): ```c void rt_wqueue_wakeup(rt_wqueue_t *queue, void *key) { // 获取链表节点 queue_list = &(queue->waiting_list); // 切换队列工作状态 queue->flag = RT_WQ_FLAG_WAKEUP; // 检查队列中是否存在等待节点 if (!(rt_list_isempty(queue_list))) { // 找到队列中第一个可用的节点唤醒 for (node = queue_list->next; node != queue_list; node = node->next) { // 通过结构体成员获取该结构体变量的地址 entry = rt_list_entry(node, struct rt_wqueue_node, list); // 只有当wakeup返回0时才能唤醒线程 -- 在自己编写唤醒函数时要注意 if (entry->wakeup(entry, key) == 0) { rt_thread_resume(entry->polling_thread); // 唤醒线程 need_schedule = 1; rt_list_remove(&(entry->list)); // 将等待节点从队列中移除 break; } } } // 调度 if (need_schedule) rt_schedule(); } ``` 其中参数的作用: | 参数名称 | 描述 | | --- | --- | | queue | 由对应资源维护的等待队列 | | key | 作为参数传递给唤醒回调函数 | 函数调用: ```c int key = 0; rt_wqueue_wakeup(&queue, (void*)&key); ``` 整个过程都比较清晰,唯一需要注意的是,当调用rt_thread_resume时会检查线程是否已挂起,若未挂起,则函数直接退出,因此,当线程没有挂起时该唤醒函数是无效的。 ### 结合阻塞式加入编写一个示例 该示例使用等待队列实现了一个生产者消费者模型。 ```c #include
#include
#define DBG_TAG "waitq" #define DBG_LVL DBG_LOG #include
#define THREAD_STACK_SIZE 512 #define THREAD_PRIORITY 20 #define THREAD_SLICE 10 static rt_thread_t p; static rt_thread_t c; static rt_wqueue_t producer_queue; static rt_wqueue_t consumer_queue; static struct rt_mutex mutex; int is_full = 0; int is_empty = 0; int data = 0; void producer_func_waitq(void* para) { while(1) { rt_wqueue_wait(&producer_queue, is_empty, RT_WAITING_FOREVER); rt_mutex_take(&mutex, RT_WAITING_FOREVER); data = (data + 1) % 100; LOG_D("produce %d", data); is_full = 1; is_empty = 0; rt_wqueue_wakeup(&consumer_queue, (void*)0); rt_mutex_release(&mutex); } } void consumer_func_waitq(void* para) { while(1) { rt_wqueue_wait(&consumer_queue, is_full, RT_WAITING_FOREVER); rt_mutex_take(&mutex, RT_WAITING_FOREVER); LOG_D("consume %d", data); is_empty = 1; is_full = 0; rt_wqueue_wakeup(&producer_queue, (void*)0); rt_mutex_release(&mutex); } } void waitq_sample(void) { is_empty = 1; rt_mutex_init(&mutex, "mutex", RT_IPC_FLAG_PRIO); rt_wqueue_init(&producer_queue); rt_wqueue_init(&consumer_queue); p = rt_thread_create("p", producer_func_waitq, NULL, THREAD_STACK_SIZE, THREAD_PRIORITY, THREAD_SLICE); c = rt_thread_create("c", consumer_func_waitq, NULL, THREAD_STACK_SIZE, THREAD_PRIORITY, THREAD_SLICE); if(p && c) { rt_thread_startup(p); rt_thread_startup(c); } } MSH_CMD_EXPORT(waitq_sample, "waitq sample"); ``` 运行结果: 生产者每生产一次数据就唤醒消费者将数据消费掉,而消费者每次消费完数据都将生产者唤醒,最后呈现出两者交替运行的结果。 ![screenshot_image.png](https://oss-club.rt-thread.org/uploads/20240415/5e0bfc1e17b19676111807f3b0c60a4e.png) ### 结合非阻塞式加入编写一个示例 简单介绍一下示例运行: 示例创建了四个线程,三个生产数据的线程p1, p2, p3和一个消费数据的线程c。 将线程c分别加入到p1, p2, p3管理的等待队列中,然后主动挂起,当p1,p2,p3任意一个线程准备好了数据就将线程c唤醒消费数据。 ```c #include
#include
#include
#include
#include
#include
#define DBG_TAG "waitq_unblock" #define DBG_LVL DBG_LOG #include
#define THREAD_STACK_SIZE 512 #define THREAD_PRIORITY 20 #define THREAD_SLICE 10 static rt_thread_t p1; static rt_thread_t p2; static rt_thread_t p3; static rt_thread_t c; static rt_wqueue_t c1_queue; static rt_wqueue_t c2_queue; static rt_wqueue_t c3_queue; static int is_full[3]; static void p1_func_waitq(void* para) { int data = 1; unsigned int delay_time = 0; while(1) { // 延迟随机时间 delay_time = rand() % 1000; rt_thread_mdelay(delay_time); LOG_D("%s set %d", rt_thread_self()->parent.name, data); is_full[0] = 1; rt_wqueue_wakeup(&c1_queue, (void*)&data); } } static void p2_func_waitq(void* para) { int data = 2; unsigned int delay_time = 0; while(1) { // 延迟随机时间 delay_time = rand() % 1000; rt_thread_mdelay(delay_time); LOG_D("%s set %d", rt_thread_self()->parent.name, data); is_full[1] = 2; rt_wqueue_wakeup(&c2_queue, (void*)&data); } } static void p3_func_waitq(void* para) { int data = 3; unsigned int delay_time = 0; while(1) { // 延迟随机时间 delay_time = rand() % 1000; rt_thread_mdelay(delay_time); LOG_D("%s set %d", rt_thread_self()->parent.name, data); is_full[2] = 1; rt_wqueue_wakeup(&c3_queue, (void*)&data); } } static int wq_wake(struct rt_wqueue_node *wait, void *key) { wait->key = *(int*)key; return __wqueue_default_wake(wait, key); } static void c_func_waitq(void* para) { int i = 0; struct rt_wqueue_node *nodes[3]; DEFINE_WAIT_FUNC(n1, wq_wake); DEFINE_WAIT_FUNC(n2, wq_wake); DEFINE_WAIT_FUNC(n3, wq_wake); nodes[0] = &n1; nodes[1] = &n2; nodes[2] = &n3; while(1) { rt_wqueue_add(&c1_queue, &n1); rt_wqueue_add(&c2_queue, &n2); rt_wqueue_add(&c3_queue, &n3); rt_thread_mdelay(10000); for(i = 0; i < 3; i++) { if(is_full[i]) { LOG_D("Get data from p%d", nodes[i]->key); } is_full[i] = 0; } // 清理 rt_wqueue_remove(&n1); rt_wqueue_remove(&n2); rt_wqueue_remove(&n3); } } void waitq_unblock_sample(void) { int i = 0; srand(time(NULL)); rt_wqueue_init(&c1_queue); rt_wqueue_init(&c2_queue); rt_wqueue_init(&c3_queue); for(i = 0; i < 3; i++) { is_full[i] = 0; } p1 = rt_thread_create("p1", p1_func_waitq, NULL, THREAD_STACK_SIZE, THREAD_PRIORITY, THREAD_SLICE); p2 = rt_thread_create("p2", p2_func_waitq, NULL, THREAD_STACK_SIZE, THREAD_PRIORITY, THREAD_SLICE); p3 = rt_thread_create("p3", p3_func_waitq, NULL, THREAD_STACK_SIZE, THREAD_PRIORITY, THREAD_SLICE); c = rt_thread_create("c", c_func_waitq, NULL, THREAD_STACK_SIZE, THREAD_PRIORITY, THREAD_SLICE); if(p1 && p2 && p3 && c) { rt_thread_startup(p1); rt_thread_startup(p2); rt_thread_startup(p3); rt_thread_startup(c); } } MSH_CMD_EXPORT_ALIAS(waitq_unblock_sample, waitq_unblock, "waitq unblock sample"); ``` 运行结果: p1,p2,p3线程都不规律的生产数据,每次生产数据之后线程c都会马上消费。有可能出现同时多个线程同时生产好数据,并同时唤醒c线程的情况,这个时候线程c就会一次性消费多个数据,但是这种情况确实非常难出现。 ![screenshot_image.png](https://oss-club.rt-thread.org/uploads/20240415/67ce577534e2805adc4de0b1cee8e1d9.png.webp) ## POLL机制 ### 简介 `poll` 是一个用于多路复用的系统调用,在 Linux 和类 Unix 系统中使用广泛。它允许程序同时监视多个文件描述符,以确定它们是否准备好进行 I/O 操作(如读取、写入、异常等)。通过 `poll`,程序可以避免使用阻塞式 I/O 调用,提高程序的效率和响应速度。 考虑一个多线程网络服务器的情况。服务器同时处理多个客户端连接,每个连接都需要监听读写事件以及处理可能的异常情况。在这种情况下,使用poll机制非常重要。 如果没有poll机制,服务器可能需要为每个客户端连接创建一个单独的线程来处理事件监听和响应。这会导致线程数量的快速增长,造成资源浪费和系统性能下降。 相比之下,使用poll机制可以使用一个线程同时监听多个连接的事件,并在事件发生时及时作出响应,避免了为每个连接创建单独线程的开销。这样可以更高效地利用系统资源,提高服务器的性能和可扩展性。 接下来,让我们深入探讨一下在poll函数的实现中,等待队列发挥了什么作用。 ### poll函数 在了解poll机制之前我们需要先了解poll函数的使用方式。 重要结构体: 我们在下文称它为文件状态结构体 ```c struct pollfd { int fd; short events; short revents; }; ``` 这个结构体一般由使用者定义: ```c struct pollfd pfd; pfd.fd = socket(PF_INET, SOCK_DGRAM, 0); // 文件描述符 pfd.events = POLLIN | POLLOUT; // 文件目标状态 pfd.revents = 0; // 文件当前状态,初始值为0,在调用poll函数之后可读取该值查看文件状态 ``` poll函数声明: ```c int poll(struct pollfd *fds, nfds_t nfds, int timeout) ``` 参数说明: | 参数名称 | 描述 | | -------- | ------------------------------------------------------ | | fds | 文件状态数组 | | nfds | 文件状态数组的大小 | | timeout | 当查询的所有文件状态都不满足要求时,线程挂起的最长时间 | ### poll的运行机制 poll会查询每个文件的状态,对每个文件的具体的查询步骤如下: 1. 通过fd获取文件结构体 2. 调用文件的**驱动函数****poll**获取文件状态 3. 驱动函数poll主要干了两件事: - 将线程加入到等待队列中(非阻塞) - 获取文件状态,并将文件状态返回 - 检查文件当前状态是否满足目标状态,如果状态满足则计数器加一(计数器用于统计可用文件的数量) 4. 将查询的文件状态赋值给revents 当所有文件都查询完毕之后,会对计数器进行判断: - 如果计数器值为0 -- 即所有文件都不满足目标状态,那么就将线程挂起 - 线程挂起后有两种情况 - 查询的所有文件状态都一直没有改变(一直没有唤醒线程),那么线程就会一直等待直至超时退出 - 存在文件状态发生改变并将线程唤醒,那么线程就会重新查询所有文件的状态 - 如果存在文件状态满足目标状态则退出 - 如果还是没有文件满足目标状态那么就继续挂起 - 如果计数器值不为0,那么就将计数器值返回 ### POLL的核心执行流程 ![screenshot_image.png](https://oss-club.rt-thread.org/uploads/20240415/cddfad773b1abd59a3847ca7b11df247.png.webp) 流程图基本反映了poll的运行流程,整个流程还是比较复杂的,但目的只有一个:查询文件状态。 ### 等待队列的作用 等待队列在两个地方出现了: 1. 在驱动poll函数中将线程加入到等待队列 1. 文件状态变化,将线程唤醒 我们发现等待队列在其中的作用就像是预约,将线程自己加入到所有文件的等待队列中就像线程预约了 所有文件一样,线程好像在跟文件说:“有什么变动你就通知我”。 想象一下,每个文件就像是一家理发店,我们有一个理发店电话簿(文件状态数组)。然后,我们逐个 给每家理发店打电话预约(遍历并加入等待队列),如果其中有一家理发店有空位了(文件状态改 变),店主就会打电话通知你过去剪头发(唤醒),如果我们不喜欢给我们理发的托尼老师(文件状态 不满足目标),那我们就继续等待(继续挂起)。当然如果理发店一直没有通知,或者一直没有等待我 们想要的托尼老师,而我们又有事情要忙,那么就可以取消预约,忙自己的事情去(超时退出)
2
条评论
默认排序
按发布时间排序
登录
注册新账号
关于作者
比特饼干
这家伙很懒,什么也没写!
文章
10
回答
1
被采纳
0
关注TA
发私信
相关文章
1
rt-thread的学习疑惑
2
基于stm32的RTT在RTT Studio IDE环境中的启动顺序求解
3
关于 rt_object_detach 脱离内核对象函数的作用求解
4
RT-Thread内核什么时候考虑加入MPU功能?
5
rt_hw_board_init中开中断后,触发SysTick_Handler
6
Cortex-M0在bootloader环境下的上下文切换问题?
7
关于ART-PI的bootloader是怎么烧写进去的
8
为什么内核代码和bootloader的代码一样的
9
线程对象结构体为什么不直接选择继承内核对象?
10
使用rt_memset给线程栈初始化,为什么选择字符‘#’,而不是‘\0’?
推荐文章
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总线
ART-Pi
FinSH
USB
DMA
文件系统
RT-Thread
SCons
RT-Thread Nano
线程
MQTT
STM32
RTC
rt-smart
FAL
ESP8266
I2C_IIC
WIZnet_W5500
ota在线升级
UART
flash
packages_软件包
cubemx
PWM
freemodbus
BSP
潘多拉开发板_Pandora
定时器
ADC
GD32
flashDB
socket
中断
Debug
编译报错
msh
SFUD
rt_mq_消息队列_msg_queue
keil_MDK
C++_cpp
ulog
at_device
本月问答贡献
出出啊
1515
个答案
342
次被采纳
小小李sunny
1438
个答案
289
次被采纳
张世争
786
个答案
169
次被采纳
crystal266
546
个答案
161
次被采纳
whj467467222
1222
个答案
148
次被采纳
本月文章贡献
出出啊
1
篇文章
6
次点赞
小小李sunny
1
篇文章
1
次点赞
张世争
1
篇文章
2
次点赞
crystal266
2
篇文章
2
次点赞
whj467467222
2
篇文章
1
次点赞
回到
顶部
发布
问题
投诉
建议
回到
底部