第九章-线程

初始时,CPU的执行流为进程;当产生了线程概念后,CPU执行流变为了线程,大大增大了一个周期以内进程的执行速度。

线程产生的作用就是为了提速,利用线程提速,原理就是实现多个执行流的伪并行,让处理器多执行自己进程中的代码。若进程只占用处理器的一个时间片,那将进程细分为线程后,一个进程中的多个线程可同时占用处理器。类比拼车与不可拼车两种模式,CPU相当于一辆车,拼车表示开启线程,允许拼车后,用户的出行成本就大大降低了。

Ⅰ.程序、进程、线程关系

程序是指静态的、存储在文件系统上、尚未运行的指令代码,它是实际运行时程序的映像。

进程是指正在运行的程序,即进行中的程序,程序必须在获得运行所需要的各类资源后才能成为进程,资源包括进程所使用的栈,使用的寄存器等。

进程=线程+资源,线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

1.进程线程区别
  1. 进程是资源分配的基本单位,线程是处理器调度的基本单位。
  2. 进程拥有自己独立的地址空间,每启动一个进程,系统为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段;线程没有自己的地址空间,需要借助进程的资源“生存”。
  3. CPU切换线程的开销比进程小。
  4. 创建线程的资源开销比进程小。
  5. 线程之间通信更方便,同一个进程下,线程共享全局变量,静态变量等数据,进程之间的通信需要以通信的方式(IPC)进行。
  6. 多进程程序更安全,生命力更强,一个进程死掉不会对另一个进程造成影响(源于有独立的地址空间);多线程程序更不易维护,一个线程死掉,可能整个进程就死掉了(因为共享地址空间)。
  7. 进程对资源保护要求高,开销大,效率相对较低,线程资源保护要求不高,但开销小,效率高,可频繁切换。
2.线程进程状态

初始态、就绪态、运行态、阻塞态、终止态

在这里插入图片描述

3.进程的身份证-PCB

针对多任务处理系统中,任务切换执行存在的如下疑问:

(1)要加载一个任务上处理器运行,任务由哪来?也就是说,调度器从哪里才能找到该任务?进程表
(2)即使找到了任务,任务要在系统中运行,其所需要的资源从哪里获得?PCB中的寄存器和栈等资源
(3)即使任务已经变成进程运行了,此进程应该运行多久呢?总不能让其独占处理器吧。时间片
(4)即使知道何时将其换下处理器,那当前进程所使用的这一套资源(寄存器内容)应该存在哪里?PCB最顶层的寄存器映像
(5)进程被换下的原因是什么?下次调度器还能把它换上处理器运行吗?取决于PCB的状态
(6)前面都说过了,进程独享地址空间,它的地址空间在哪里? PCB-页表

……

为解决以上问题,操作系统为每个进程提供了一个 PCB,Process Control Block,即程序控制块,它就是进
程的身份证,用它来记录与此进程相关的信息,比如进程状态、PID、优先级等。

每个进程都有自己的 PCB,所有 PCB 放到一张表格中维护,这就是进程表,调度器可以根据这张表选择上处理器运行的进程 。

在这里插入图片描述

PCB的栈为进程所使用的 0 特权级下内核栈,寄存器映像存储的也是内核态的寄存器,栈指针对应的是内核态下的寄存器映像地址,即内核态栈地址。

3.实现线程的两种方式一一内核或用户进程

线程仅仅是个执行流,在用户空间,还是在内核空间实现它,最大的区别就是线程表在哪里,由谁来调度它上处理器 。 如果线程在用户空间中实现,线程表就在用户进程中,用户进程就要专门写个线程用作线程调度器,由它来调度进程内部的其他线程。如果线程在内核空间中实现,线程表就在内核中,该线程就会由操作系统的调度器统一调度,无论该线程属于内核,还是用户进程。

(1)内核中实现线程

线程机制由内核完成。

好处:

  • 相比在用户空间中实现线程,内核提供的线程相当于让进程多占了处理器资源
  • 当进程中的某一线程阻塞后 , 由于线程是由内核空间实现的,操作系统认识线程,所以就只会阻塞这一个线程,此线程所在进程内的其他线程将不受影响

缺点:

  • 用户进程需要通过系统调用陷入内核,这多少增加了 一些现场保护的栈操作,这还是会消耗一些处理器时间
(2)用户程序中实现线程

**线程机制由用户程序通过标准库完成。**处理器依旧按照进程作为执行流执行程序。

好处:

  • 可移植性强,在不支持线程的操作系统上也可以写出完美支持线程的用户程序。
  • 线程的调度算法是由用户程序自己实现的,可以根据实现应用情况为某些线程加权调度。
  • 将线程的寄存器映像装载到 CPU 时,可以在用户空间完成,即不用陷入到内核态,这样就免去了进入内核时的入栈和出栈操作

缺点:

  • 进程中的某个线程若出现了阻塞(通常是由于系统调用造成的),操作系统不知道进程中存在线程,它以为此进程是传统型进程(单线程进程),因此会将整个进程挂起,即进程中的全部线程都无法运行
  • 如果在用户空间中实现线程,但凡进程中的某个线程开始在处理器上执行后,只要该线程不主动让出处理器,此进程中的其他线程都没机会运行。 只能凭借开发人员“人为”地在线程中调用类似 pthread_yield 或 pthread_exit 之类的方法使线程让出处理器使用权,此类方法通过回调方式触发进程内的线程调度器,让调度器有机会选择进程内的其他线程上处理器运行。
  • 进程在一个时间片内既要完成资源分配,又要处理线程调度的工作,导致提速效果较差

如果在用户空间中实现线程,用户线程就要肩负起调度器的责任,因此除了要实现进程内的线程调度器外,还要自己在进程内维护线程表 ,导致开销很大。

在这里插入图片描述

Ⅱ.在内核空间实现线程

包括主线程和新建立的线程两种方式。

在这里插入图片描述

call 指令属于“有去有回”的指令,它在“去”之前先在栈中(进入被调函数时的栈顶处〉留下返回地址,它的“回”则需要在 ret 指令的配合下才能完成, ret 将楼顶的值当作 call 留下的返回地址,在保证栈顶值正确的情况下, ret 能把处理器重新带回到主调函数中。

1.线程执行过程
1.构建就绪线程队列和所有线程队列
struct task_struct* main_thread;    // 主线程 PCB
struct list* thread_ready_list;     // 就绪队列
struct list* thread_all_list;       // 所有线程
static struct list_elem* thread_tag;    // 用于保存队列中的线程结点

队列中存储的是PCB标志位,general_tag和all_list_tag,分别表示就绪线程队列标志和所有线程队列标志。

在这里插入图片描述

/*  根据结构体成员找到结构体地址    */
// 计算偏移量,建立一个起始地址为0的虚拟结构体,对成员取地址,就是偏移量
#define offset(struct_type, member) (int)(&((struct_type*)0)->member)
// 结构体入口地址=当前成员变量地址-当前成员变量的偏移地址
#define elem2entry(struct_type, struct_member_name, elem_ptr) \
           (struct_type*)((int)elem_ptr - offset(struct_type, struct_member_name))
2.申请一页大小的PCB(内核空间)
struct task_struct* thread= get_kernel_pages(l);  
3.初始化线程PCB
(1)定义PCB结构
/*   进程控制块     */
struct task_struct{
    uint32_t *self_kstack;   // 线程的内核栈
    enum THREAD_STATUS status;        // 线程状态
    uint32_t priority;          // 线程优先级
    char name[16];
    
    // 此任务自上 cpu 运行后至今占用了多少 cpu 嘀嗒数,也就是此任务执行了多久
    uint32_t elapsed_ticks;
    
    // general_tag 的作用是用于线程在一般的队列中的结点
    struct list_elem general_tag;

    // all_list_tag 的作用是用于线程队列 thread_all_ list 中的结点
    struct list_elem all_list_tag;

    uint32_t* pgdir; // 进程自己页表的虚拟地址
    uint32_t stack_magic;       // 线程栈的边界标记,用于标记栈是否溢出
};
  • 内核栈。存储线程执行的函数、传参、寄存器、内存地址等信息

  • 任务的时间片。每次时钟中断都会将当前任务的 ticks 减1 ,当减到 0时就被换下处理器。

  • 优先级。priority 表示任务的优先级,咱们这里优先级体现在任务执行的时间片上,即优先级越高,每次任务被调度上处理器后执行的时间片就越长。

  • general_tag。线程的标签, 当线程被加入到就绪队列也thread_ready_list 或其他等待队列中时,就把该线程 PCB 中 general_tag 的地址加入队列。

  • all_list_tag。在所设计的系统中, 为管理所有线程,还存在一个全部线程队列thread_all_list,因此线程还需要另外一个标签,即 all_list_tag。专用于线程被加入全部线程队列时使用

    这两个标签仅仅是加入队列时用的,将来从队列中把它们取出来时,还需要再通过 offset 宏与 elem2entry宏的“反操作“实现从&general_tag 到&thread 的地址转换,将它们还原成线程的 PCB 地址后才能使用。

  • pgdir。任务自己的页表 。如果该任务为线程, pgdir 则为 NULL,否则 pgdir会被赋予页表的虚拟地址,注意此处是虚拟地址,页表加载时还是要被转换成物理地址的 。

如何实现就绪队列的标签到PCB转换的过程呢?通过上面的offset函数和elem2entry函数

(2)初始化PCB

/*  初始线程PCB   */
void init_thread(struct task_struct* pthread, char* name, uint32_t proi){
    memset(pthread,0, sizeof(*pthread));
    strcpy(pthread->name, name);
    if (pthread == main_thread){
        pthread->status = TASK_RUNNING;
    }
    else{
        pthread->status = TASK_READY;
    }
    
    // 初始化在线程PCB的最顶端,栈向下生长
    // pthread在分配了一页内存后指向PCB的最底端,加上PG_SIZE即为PCB最顶端地址
    pthread->self_kstack =(uint32_t*) ((uint32_t)pthread + PG_SIZE);
    
    pthread->priority = proi;
    pthread->ticks = proi;
    pthread->elapsed_ticks = 0;
    pthread->pgdir = NULL;
    pthread->stack_magic = 0x19870916;  // 自定义魔数
}

分配栈空间

初始化PCB中优先级、时间片、状态

3.初始化线程栈
(1)定义线程栈

线程栈结构,由于线程中断后再次执行需要恢复执行的函数、参数,以及对应的寄存器信息,因此建立栈维护线程现场

/*  建立线程自己的栈 
	* 线程自己的栈,用于存储线程中待执行的函数
	* 此结构在线程自己的内核梭中位置不固定,
	* 仅用在 switch_to 时保存线程环境。
	* 实际位置取决于实际运行情况。
*/
struct thread_stack{
    uint32_t ebp;
    uint32_t ebx;
    uint32_t edi;
    uint32_t esi;
	/* 线程第一次执行时, eip 指向待调用的函数 kernel_thread
	 其他时候, eip 是指向 switch_ to 的返回地址*/
    void (*eip) (thread_func* func, void* func_arg);
    
	/*** 以下仅供第一次被调度上 cpu 时使用 ****/
    void (*unused_retaddr);		// 参数 unused_retaddr 只为占位置充数为返回地址
    thread_func* funciton;		// 自 kernel_thread 所调用的函数名
    void* func_arg;				// 由 kernel_thread 所调用的函数所需的参数
};
(2)初始化线程栈
/*  初始化线程栈    */
void thread_creat(struct task_struct* pthread, thread_func* function, void* func_arg){
    // 先预留出中断栈空间
    pthread->self_kstack -= sizeof(struct intr_stack);
    // 预留出线程栈空间
    pthread->self_kstack -= sizeof(struct thread_stack);

    // 设置线程栈起始地址
    struct thread_stack* kthread_stack = (struct thread_stack*) pthread->self_kstack;
    kthread_stack->funciton = function;
    kthread_stack->func_arg = func_arg;
    kthread_stack->eip = kernel_thread;
    kthread_stack->ebp = kthread_stack->ebx = kthread_stack->edi = kthread_stack->esi = 0;    
}
4.将创建的线程添加到就绪线程队列和所有线程队列中
    /*  确保之前不在就绪队列中  */
    ASSERT(!(elem_find(&thread_ready_list, &thread->general_tag)));
    list_append(&thread_ready_list, &thread- >general_tag);

    /*  确保之前不在所有队列中  */
    ASSERT(!(elem_find(&thread_ready_list, &thread->all_list_tag)));
    list_append(&thread_ready_list, &thread- >all_list_tag);
5.设置kernel中main函数为主线程

main函数在启动之后会自动建立线程栈,我们在kernel.S中为其分配了PCB空间,并未初始化PCB,因此需要对其进行初始化 。

(1)获取当前esp指针赋值给main_thread的PCB
/*  获取当前线程 pcb 指针   */
struct task_struct* running_thread(){
    uint32_t esp;
    asm("mov %%esp, %0":"=g"(esp));
    /* 取 esp 整数部分,即 pcb 起始地址*/
    return (struct task_struct*) (esp & Oxfffff000);
}
(2)完善main_threadPCB信息
static void make_main_thread(void){
    // 定义main_thread的PCB地址
    main_thread = running_thread();
    // 初始化名字和优先级
    init_thread(main_thread, "main", 31);
    
    // 由于main程序已经在运行了,因此只需要放入all_list即可
    ASSERT(!elem_find(&thread_all_list, &main_thread->all_list_tag));
    list_append(&thread_all_list, &main_thread->all_list_tag);
}

Ⅲ.任务调度器和任务切换

1.任务调度器工作过程

线程调度器主要任务就是读写就绪队列,增删里面的结点,结点是线程 PCB 中的 general_tag,“相当于”线程的 PCB,从队列中将其取出时一定要还原成 PCB 才行 。

  • 当前任务执行的状态,什么时候结束?ticks===0
  • 结束了之后,如何完成保护现场操作?
  • 结束了如何找到下一个线程?thread_ready_list
  • 如何恢复下一个线程的现场?switch_to

(1)PCB的ticks决定了任务执行的时间,系统设定:当ticks减为0时,当前任务被换下处理器,根据线程的运行状态决定线程是否加入就绪队列:

  • TASK-RUNNING。将当前队列加入thread_ready_list队尾,并将ticks设置为prio。
  • others。不对当前线程进行任何操作。

故需要设定时钟中断系统。

(2)调度器按照队列先进先出的顺序,把就绪队列中的第 1 个结点作为下一个要运行的新线程,将该线程的状态置为 TASK_RUNNING,之后通过函数 switch_to 将新线程的寄存器环境恢复,新线程便开始执行 。

(1)完整调度过程的3个步骤
  1. 时钟中断处理函数
  2. 调度器schedule
  3. 任务切换函数switch_to
(2)PART1-注册时钟中断处理函数

Intel处理器支持256个中断,在前面的kernel.S中,通过中断向量号调用中断处理程序数组 idt_table 中的 C 版本的处理程序,就是文件 kemel.S 中代码 call [idt_table + %1 *4]的作用。由于idt_table存储的就是中断处理程序,因此,为设备注册中断处理程序的工作变得很简单,我们不用去修改中断描述符,直接把中断向量作为数组下标,去修改 idt_table[中断向量]数组元素即可。

   for (i = 0; i < IDT_DESC_CNT; i++) {
	/* idt_table数组中的函数是在进入中断后根据中断向量号调用的,
	 * 见kernel/kernel.S的call [idt_table + %1*4] */
      idt_table[i] = general_intr_handler;		    // 默认为general_intr_handler。
							    // 以后会由register_handler来注册具体处理函数。
      intr_name[i] = "unknown";				    // 先统一赋值为unknown 
   }

之前的时钟中断处理函数还是用通用的函数来处理的, 即 general_intr_handler,此函数作为默认的中断处理函数,即某个中断源没有中断处理程序时才用它来代替。

(2.1)改进的通用中断处理函数general_intr_handler()
  • 由于需要打印输出中断信息,为防止由于光标错误值引发异常,加入了set_cursor()光标位置设置函数

  • 为保证中断调用出现的缺页异常能及时被发现和处理,设置缺页中断号14判定函数,标定缺页异常

    加了 Pagefault 的处理。 Pagefault 就是通常所说的缺页异常,它表示虚拟地址对应的物理地址不存在,也就是虚拟地址尚未在页表中分配物理页,这样会导致 Pagefault 异常。导致 Pagefault 的虚拟地址会被存放到控制寄存器 CR2 中,我们加入的内联汇编代码就是让 Pagefault 发生时,将寄存器 cr2 中的值转储到整型变量 page_fault_vaddr 中,并通过 put_str函数打印出来。因此,如果程序运行过程中出现异常 Pagefault 时,将会打印出导致 Pagefault 出现的虚拟地址。

/* 通用的中断处理函数,一般用在异常出现时的处理 */
static void general_intr_handler(uint8_t vec_nr) {
   if (vec_nr == 0x27 || vec_nr == 0x2f) {	// 0x2f是从片8259A上的最后一个irq引脚,保留
      return;		//IRQ7和IRQ15会产生伪中断(spurious interrupt),无须处理。
   }
  /* 将光标置为0,从屏幕左上角清出一片打印异常信息的区域,方便阅读 */
   set_cursor(0);
   int cursor_pos = 0;
   while(cursor_pos < 320) {
      put_char(' ');
      cursor_pos++;
   }

   set_cursor(0);	 // 重置光标为屏幕左上角
   put_str("!!!!!!!      excetion message begin  !!!!!!!!\n");
   set_cursor(88);	// 从第2行第8个字符开始打印
   put_str(intr_name[vec_nr]);
   if (vec_nr == 14) {	  // 若为Pagefault,将缺失的地址打印出来并悬停
      int page_fault_vaddr = 0; 
      asm ("movl %%cr2, %0" : "=r" (page_fault_vaddr));	  // cr2是存放造成page_fault的地址
      put_str("\npage fault addr is ");
      put_int(page_fault_vaddr); 
   }
   put_str("\n!!!!!!!      excetion message end    !!!!!!!!\n");
  // 能进入中断处理程序就表示已经处在关中断情况下,
  // 不会出现调度进程的情况。故下面的死循环不会再被中断。
   while(1);
}
(2.2)注册时钟中断处理函数
……
    uint32_t ticks;
……
static void intr_timer_handler(void){
    struct task_struct* cur_thread = running_thread();
    ASSERT(cur_thread->stack_magic == 0x19870916);

    cur_thread->elapsed_ticks++;        // 记录占用 CPU的时间
    ticks++;                            // 从内核开始处理第一次中断后开始至今的滴答数

    if(cur_thread->ticks == 0){
        schedule();
    }
    else{
        cur_thread->ticks--;
    }
}

void timer_init(){
    put_str("timer init");
    // 设置 8253 的定时周期,也就是发中断的周期 
    frequency_set( CNTRERO_PORT, \
    COUNTERO_NO, \
    READ_WRITE_LATCH, \
    COUNTER MODE, \
    COUNTERO_VALUE);
    // 注册时钟中断函数
    register_handler(0x20, intr_timer_handler);
    put_str("timer_init done\n") ;
}
(2.3)中断注册函数
/*  中断处理程序数组第 vector_no 个元素中
注册安装中断处理程序 function   */
void register_handler(uint32_t vec_no, intr_handler* function){
    /*  idt_table 数组中的函数是在进入中断后根据中断向量号调用的
    * 见 kernel/kernel.S 的 call [idt_table+%1*4]  */
    idt_table[vec_no] = function;
}
(3)PART2-调度器schedule
/*  实现任务调度    */
void schedule(){
    // 系统关中断下进行
    ASSERT(intr_get_status == INTR_OFF);

    struct task_struct *cur_thread = running_thread();
    
    // 若此线程只是 cpu 时间片到了,将其加入到就绪队列尾
    if(cur_thread->status == TASK_RUNNING){
        ASSERT(&thread_ready_list, &cur_thread->general_tag);
        list_append(&thread_ready_list, &cur_thread->general_tag);
        // 重新将当前线程的 ticks 再重置为其 priority
        cur_thread->ticks = cur_thread->priority;
        cur_thread->status = TASK_READY;
    }
    else{
        /*  若此线程需要某事件发生后才能继续上 cpu 运行,
            不需要将其加入队列,因为当前线程不在就绪队列中  */
    }
    ASSERT(list_empty(&thread_ready_list));
    /* 从就绪队列取出下一个就绪线程*/
    // 清空线程节点
    thread_tag = NULL;
    thread_tag = list_pop(&thread_ready_list);
    struct task_struct *next_thread = elem2entry(struct task_struct, general_tag, thread_tag);
    next_thread->status = TASK_RUNNING;
    // 载入寄存器
    switch_to(cur_thread, next_thread);
}
  1. 判断当前线程是否需要继续执行,决定是否重新加入就绪队列
  2. 从就绪队列pop出下一个线程
  3. switch_to载入新线程寄存器组

要求在关中断下进行

(4)PART3-任务切换函数switch_to

任务切换的过程包括:保存当前任务上下文;执行中断处理程序,保存当前中断处理程序上下文;执行下一个任务,因此

需要保存任务的上下文,既需要保存中断发生时任务的寄存器、栈状态,同时也要保存内核中任务的还未执行的环境。具体包括两部分:

在这里插入图片描述

(1)上下文保护的第一部分负责保存任务进入中断前的全部寄存器,目的是能让任务恢复到中断前 。

通过kernel.S完成中断前的保存工作,以及中断处理程序跳转执行工作

extern idt_table		 ;idt_table是C中注册的中断处理程序数组

%macro VECTOR 2
section .text
intr%1entry:		 ; 每个中断处理程序都要压入中断向量号,所以一个中断类型一个中断处理程序,自己知道自己的中断向量号是多少

   %2				 ; 中断若有错误码会压在eip后面 
; 以下是保存上下文环境
   push ds
   push es
   push fs
   push gs
   pushad			 ; PUSHAD指令压入32位寄存器,其入栈顺序是: EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI

   ; 如果是从片上进入的中断,除了往从片上发送EOI外,还要往主片上发送EOI 
   mov al,0x20                   ; 中断结束命令EOI
   out 0xa0,al                   ; 向从片发送
   out 0x20,al                   ; 向主片发送

   push %1			 ; 不管idt_table中的目标程序是否需要参数,都一律压入中断向量号,调试时很方便
   call [idt_table + %1*4]       ; 调用idt_table中的C版本中断处理函数
   jmp intr_exit

section .data
   dd    intr%1entry	 ; 存储各个中断入口程序的地址,形成intr_entry_table数组
%endmacro

section .text
global intr_exit
intr_exit:	     
; 以下是恢复上下文环境
   add esp, 4			   ; 跳过中断号
   popad
   pop gs
   pop fs
   pop es
   pop ds
   add esp, 4			   ; 跳过error_code
   iretd

(2)上下文保护的第二部分负责保存这 4 个寄存器 esi 、 edi 、 ebx 和 ebp ,目的是让任务恢复执行在任务切换发生时剩下尚未执行的内核代码,保证顺利走到退出中断的出口,利用第一部分保护的寄存器环境彻底恢复任务。

[bits 32]
section .text
global switch_to
switch_to:
   ;栈中此处是返回地址	       
   push esi
   push edi
   push ebx
   push ebp

   mov eax, [esp + 20]		 ; 得到栈中的参数cur, cur = [esp+20]
   mov [eax], esp                ; 保存栈顶指针esp. task_struct的self_kstack字段,
				 ; self_kstack在task_struct中的偏移为0,
				 ; 所以直接往thread开头处存4字节便可。
;------------------  以上是备份当前线程的环境,下面是恢复下一个线程的环境  ----------------
   mov eax, [esp + 24]		 ; 得到栈中的参数next, next = [esp+24]
   mov esp, [eax]		 ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,
				 ; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
   pop ebp
   pop ebx
   pop edi
   pop esi
   ret				 ; 返回到上面switch_to下面的那句注释的返回地址,
				 ; 未由中断进入,第一次执行时会返回到kernel_thread

在这里插入图片描述

(5)PART4-启用线程调度

系统初始化函数中加入thread_init()函数,在main函数中加入thread_start()函数。

开启执行……

总结:

pushad 

本指令将EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI 这8个32位通用寄存器依次压入堆栈,其中SP的值是在此条件指令未执行之前的值,压入堆栈之后,ESP-32–>ESP。

popad

本指令依次弹出堆栈中的32位字到 EDI,ESI,EBP,ESP,EBX,EDX,ECX,EAX中,弹出堆栈之后,ESP+32–>ESP。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值