5.1 本章目标:

本章是我们真正从从 0 到 1 写 RTOS 的第一章, 属于基础中的基础, 必须要学会创建任务,并重点掌握任务是如何切换的。 因为任务的切换是由汇编代码来完成的,所以代码看起来比较难懂,但是我会尽力把代码讲得透彻。 如果本章内容学不会,后面的内容根本无从下手。
       在这章中, 我们会创建两个任务,并让这两个任务不断地切换,任务的主体都是让一个变量按照一定的频率翻转,通过 KEIL 的软件仿真功能,在逻辑分析仪中观察变量的波形变化,最终的波形图具体见图 5-1。

仿真方法如下:

其实, 图 5-1 的波形图的效果,并不是真正的多任务系统中任务切换的效果图,这个效果其实可以完全由裸机代码来实现,具体见代码清单 5-1。

/* flag 必须定义成全局变量才能添加到逻辑分析仪里面观察波形
** 在逻辑分析仪中要设置以 bit 的模式才能看到波形,不能用默认的模拟量
*/
uint32_t flag1;
uint32_t flag2;
/* 软件延时,不必纠结具体的时间 */
void delay( uint32_t count )
{
for (; count!=0; count--);
}
int main(void)
{
/* 无限循环,顺序执行 */
for (;;) {
flag1 = 1;
delay( 100 );
flag1 = 0;
delay( 100 );
flag2 = 1;
delay( 100 );
flag2 = 0;
delay( 100 );
}

在多任务系统中,两个任务不断切换的效果图应该像图 5-2 所示那样,即两个变量的波形是完全一样的,就好像 CPU 在同时干两件事一样,这才是多任务的意义。 虽然两者的波形图一样,但是,代码的实现方式是完全不一样的, 由原来的顺序执行变成了任务的主动切换, 这是根本区别。 这章只是开始, 我们先掌握好任务是如何切换, 在后面章节中,我们会陆续的完善功能代码, 加入系统调度, 实现真正的多任务。 千里之行,始于本章节,不要急。

5.2 什么是任务?

在裸机系统中, 系统的主体就是 main 函数里面顺序执行的无限循环,这个无限循环里面 CPU 按照顺序完成各种事情。在多任务系统中,我们根据功能的不同,把整个系统分割成一个个独立的且无法返回的函数,这个函数我们称为任务, 也有人称之为线程。 任务的大概形式具体见代码清单 5-2。

void Task (void *parg)
{
/* 任务主体,无限循环且不能返回 */
for (;;) {
/* 任务主体代码 */
}
}

5.3 创建任务:

5.3.1 定义任务堆栈:

我们先回想下,在一个裸机系统中, 如果有全局变量,有子函数调用,有中断发生。那么系统在运行的时候,全局变量放在哪里,子函数调用时,局部变量放在哪里, 中断发生时,函数返回地址发哪里。如果只是单纯的裸机编程,它们放哪里我们不用管,但是如果要写一个 RTOS,这些种种环境参数,我们必须弄清楚他们是如何存储的。在裸机系统中,他们统统放在一个叫栈的地方,栈是单片机 RAM 里面一段连续的内存空间,栈的大小由启动文件里面的代码配置,具体见代码清单 5-3,最后由 C库函数_main进行初始化。它们在 RAM空间里面的大概分布具体见。

代码清单 5-3 裸机系统中的栈分配

Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
initial_sp

但是,在多任务系统中,每个任务都是独立的,互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组。这些一个个的任务栈也是存在于 RAM 中,能够使用的最大的栈也是由代码清单 5-3 中的 Stack_Size 决定。 只是多任务系统中任务的堆栈就是在统一的一个栈空间里面分配好一个个独立的房间,每个任务只能使用各自的房间,而裸机系统中需要使用栈的时候则可以天马行空,随便在栈里面找个空闲的空间使用,大概的区别具体见。本章我们要实现两个变量按照一定的频率轮流的翻转,需要两个任务来实现,那么就需要定义两个任务栈,具体见代码清单 5-4。在多任务系统中,有多少个任务就需要定义多少个任务堆栈。

代码清单 5-4 定义任务堆栈

#define TASK1_STK_SIZE 128                 (1)
#define TASK2_STK_SIZE 128
static CPU_STK Task1Stk[TASK1_STK_SIZE];   (2)
static CPU_STK Task2Stk[TASK2_STK_SIZE];

代码清单 5-4 (1)任务栈的大小由宏定义控制,在 uC/OS-III 中,空闲任务的堆栈最小应该大于 128,那么我们这里的任务的堆栈也暂且配置为 128。
       代码清单 5-4 (2)任务栈其实就是一个预先定义好的全局数据,数据类型为CPU_STK。在 uC/OS-III 中,凡是涉及到数据类型的地方, uC/OS-II 都会将标准的 C 数据类型用 typedef 重新取一个类型名,命名方式则采用见名之义的方式命名且统统大写。凡是与 CPU 类型相关的数据类型则统一在 cpu.h 中定义与 OS 相关的数据类型则在 os_type.h定义。 CPU_STK 就是与 CPU 相关的数据类型,则在 cpu.h 中定义,具体见代码清单 5-5。cpu.h 首次使用则需要自行在 uC-CPU 文件夹中新建并添加到工程的 uC/CPU 这个组中。 代码清单 5-5 中除了 CPU_STK 外,其它数据类型重定义是本章后面内容需要使用到,这里统一贴出来,后面将不再赘述。

代码清单 5-5 cpu.h 中的数据类型

#ifndef CPU_H
#define CPU_Htypedef  unsigned  short       CPU_INT16U;     //无符号整型     16位,占两个字节。范围是0~65535
typedef  unsigned  int         CPU_INT32U;     //整型无符号     16位系统中一个int能存储的数据的范围为-32768~32767
typedef  unsigned  char        CPU_INT08U;     //无符号字节型   8位0~255typedef  CPU_INT32U  CPU_ADDR;/* 堆栈数据类型重定义 */
typedef  CPU_INT32U             CPU_STK;
typedef  CPU_ADDR               CPU_STK_SIZE;typedef  volatile  CPU_INT32U  CPU_REG32;      //volatile 的作用 是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。/*
简单地说就是防止编译器对代码进行优化。比如如下程序:XBYTE[2]=0x55;
XBYTE[2]=0x56;
XBYTE[2]=0x57;
XBYTE[2]=0x58;
对外部硬件而言,上述四条语句分别表示不同的操作,会产生四种不同的动作,但是编译器却会对上述四条语句进行优化,
认为只有XBYTE[2]=0x58(即忽略前三条语句,只产生一条机器代码)。
如果键入volatile,则编译器会逐一地进行编译并产生相应的机器代码(产生四条代码)。
*/#endif /* CPU_H */

5.3.2 定义任务函数:

任务是一个独立的函数,函数主体无限循环且不能返回。本章我们定义的两个任务具体见代码清单 5-6。
代码清单 5-6 任务函数

/* flag 必须定义成全局变量才能添加到逻辑分析仪里面观察波形
** 在逻辑分析仪中要设置以 bit 的模式才能看到波形,不能用默认的模拟量
*/
uint32_t flag1;               (1)
uint32_t flag2;
/* 任务 1 */
void Task1( void *p_arg )     (2)
{
for ( ;; ) {
flag1 = 1;
delay( 100 );
flag1 = 0;
delay( 100 );
}
}
/* 任务 2 */
void Task2( void *p_arg )     (3)
{
for ( ;; ) {
flag2 = 1;
delay( 100 );
flag2 = 0;
delay( 100 );
}
}

代码清单 5-6 (1) : 需要在 KEIL 的逻辑分析仪中观察波形的变量需要定义成全局变量,且要以 bit的模式观察,不能使用默认的模拟量。
       代码清单 5-6 (2)和(3) : 正如我们所说的那样,任务是一个独立的、无限循环且不能返回的函数

5.3.3 定义任务控制块 TCB:

在裸机系统中,程序的主体是 CPU 按照顺序执行的。而在多任务系统中,任务的执行是由系统调度的。系统为了顺利的调度任务,为每个任务都额外定义了一个任务控制块TCB(Task Control Block),这个任务控制块就相当于任务的身份证,里面存有任务的所有信息,比如任务的堆栈,任务名称,任务的形参等。有了这个任务控制块之后,以后系统对任务的全部操作都可以通过这个 TCB 来实现。 TCB 是一个新的数据类型, 在 os.h(os.h 第一次使用需要自行在文件夹 uCOS-III\Source 中新建并添加到工程的 uC/OS-IIISource 组) 这个头文件中声明,有关 TCB 具体的声明见代码清单 5-7, 使用它可以为每个任务都定义一个 TCB 实体。
代码清单 5-7 任务控制块 TCB 类型声明

/* 任务控制块重定义 */
typedef struct os_tcb OS_TCB;    (1)
/* 任务控制块 数据类型声明 */
struct os_tcb {                  (2)
CPU_STK *StkPtr;
CPU_STK_SIZE StkSize;
};

代码清单 5-7 (1) : 在 uC/OS-III 中,所有的数据类型都会重新取一个名字且用大写字母表示。
       代码清单 5-7 (2) : 目前 TCB 里面的成员还比较少, 只有堆栈指针和堆栈大小。其中为了以后操作方便,我们把堆栈指针作为 TCB的第一个成员。
       在本章实验中,我们在 app.c 文件中为两个任务定义的 TCB 具体见代码清单 5-8。
代码清单 5-8 任务 TCB 定义

static OS_TCB Task1TCB;
static OS_TCB Task2TCB;

5.3.4 实现任务创建函数:

任务的堆栈,任务的函数实体,任务的 TCB 最终需要联系起来才能由系统进行统一调度。那么这个联系的工作就由任务创建函数 OSTaskCreate 来实现,该函数在 os_task.c(os_task.c 第一次使用需要自行在文件夹 uCOS-III\Source中新建并添加到工程的 uC/OS-IIISource 组)中定义,所有跟任务相关的函数都在这个文件定义。 OSTaskCreate 函数的实现具体见代码清单 5-9。
代码清单 5-9 OSTaskCreate 函数

void OSTaskCreate (OS_TCB *p_tcb,    (1)
OS_TASK_PTR p_task,                  (2)
void *p_arg,                         (3)
CPU_STK *p_stk_base,                 (4)
CPU_STK_SIZE stk_size,               (5)
OS_ERR *p_err)                       (6)
{
CPU_STK *p_sp;
p_sp = OSTaskStkInit (p_task,        (7)
p_arg,
p_stk_base,
stk_size);
p_tcb->StkPtr = p_sp;                (8)
p_tcb->StkSize = stk_size;           (9)
*p_err = OS_ERR_NONE;                (10)
}

代码清单 5-9: OSTaskCreate 函数遵循 uC/OS-III中的函数命名规则,以大小的 OS开头,表示这是一个外部函数,可以由用户调用,以 OS_开头的函数表示内部函数,只能由uC/OS-III内部使用紧接着是文件名,表示该函数放在哪个文件最后是函数功能名称。
       代码清单 5-9 (1): p_tcb 是任务控制块指针
       代码清单 5-9 (2): p_task 是任务函数名类型为 OS_TASK_PTR,原型声明在 os.h中,具体见代码清单 5-10。
代码清单 5-10 OS_TASK_PTR 原型声明

typedef void (*OS_TASK_PTR)(void *p_arg);

代码清单 5-9 (3): p_arg 是任务形参,用于传递任务参数
       代码清单 5-9 (4): p_stk_base 用于指向任务堆栈的起始地址
       代码清单 5-9 (5): stk_size 表示任务堆栈的大小
       代码清单 5-9 (6): p_err 用于存错误码, uC/OS-III 中为函数的返回值预先定义了很多错误码,通过这些错误码我们可以知道函数是因为什么出错。为了方便,我们现在把uC/OS-III 中所有的错误号都贴出来,错误码是枚举类型的数据,在 os.h 中定义,具体见代码清单 5-11。
代码清单 5-11 错误码枚举定义

typedef enum os_err {
OS_ERR_NONE = 0u,
OS_ERR_A = 10000u,
OS_ERR_ACCEPT_ISR = 10001u,
OS_ERR_B = 11000u,
OS_ERR_C = 12000u,
OS_ERR_CREATE_ISR = 12001u,
/* 篇幅限制,中间部分删除,具体的可查看本章配套的例程 */
OS_ERR_X = 33000u,
OS_ERR_Y = 34000u,
OS_ERR_YIELD_ISR = 34001u,
OS_ERR_Z = 35000u
} OS_ERR;

代码清单 5-9 (7): OSTaskStkInit()是任务堆栈初始化函数。当任务第一次运行的时候,加载到 CPU 寄存器的参数就放在任务堆栈里面,在任务创建的时候,预先初始化好堆栈。 OSTaskStkInit()函数在 os_cpu_c.c(os_cpu_c.c 第一次使用需要自行在文件夹 uC-CPU中新建并添加到工程的 uC/CPU组)中定义,具体见代码清单 5-12。
代码清单 5-12 OSTaskStkInit()函数

#include "os.h"/* 任务堆栈初始化 */
CPU_STK *OSTaskStkInit (OS_TASK_PTR p_task,                      (1)
void *p_arg,                                                     (2)
CPU_STK *p_stk_base,                                             (3)
CPU_STK_SIZE stk_size)                                           (4)
{
CPU_STK *p_stk;
p_stk = &p_stk_base[stk_size];                                   (5)
/* 异常发生时自动保存的寄存器 */                                      (6)
*--p_stk = (CPU_STK)0x01000000u; /* xPSR 的 bit24 必须置 1 */
*--p_stk = (CPU_STK)p_task; /* R15(PC)任务的入口地址 */
*--p_stk = (CPU_STK)0x14141414u; /* R14 (LR) */
*--p_stk = (CPU_STK)0x12121212u; /* R12 */
*--p_stk = (CPU_STK)0x03030303u; /* R3 */
*--p_stk = (CPU_STK)0x02020202u; /* R2 */
*--p_stk = (CPU_STK)0x01010101u; /* R1 */
*--p_stk = (CPU_STK)p_arg; /* R0 : 任务形参 */
/* 异常发生时需手动保存的寄存器 */                                  (7)
*--p_stk = (CPU_STK)0x11111111u; /* R11 */
*--p_stk = (CPU_STK)0x10101010u; /* R10 */
*--p_stk = (CPU_STK)0x09090909u; /* R9 */
*--p_stk = (CPU_STK)0x08080808u; /* R8 */
*--p_stk = (CPU_STK)0x07070707u; /* R7 */
*--p_stk = (CPU_STK)0x06060606u; /* R6 */
*--p_stk = (CPU_STK)0x05050505u; /* R5 */
*--p_stk = (CPU_STK)0x04040404u; /* R4 */
return (p_stk);                                                   (8)
}

代码清单 5-12(1):  p_task是任务名,指示着任务的入口地址,在任务切换的时候,需要加载到 R15,即 PC寄存器,这样 CPU就可以找到要运行的任务。
       代码清单 5-12(2) : p_arg 是任务的形参,用于传递参数,在任务切换的时候,需要加载到寄存器 R0。 R0 寄存器通常用来传递参数。
       代码清单 5-12(3) : p_stk_base 表示任务堆栈的起始地址
       代码清单 5-12(4) : stk_size 表示任务堆栈的大小,数据类型为 CPU_STK_SIZE,在Cortex-M3 内核的处理器中等于 4 个字节,即一个字。
       代码清单 5-12(5) :获取任务堆栈的栈顶地址, ARMCM3 处理器的栈是由高地址向低地址生长的。所以初始化栈之前,要获取到栈顶地址,然后栈地址逐一递减即可。
       代码清单 5-12(6) : 任务第一次运行的时候,加载到 CPU 寄存器的环境参数我们要预先初始化好。 初始化的顺序固定, 首先是异常发生时自动保存的 8 个寄存器,即 xPSR、R15、 R14、 R12、 R3、 R2、 R1 和 R0。其中 xPSR寄存器的位 24 必须是 1, R15 PC指针必须存的是任务的入口地址, R0 必须是任务形参,剩下的 R14、 R12、 R3、 R2 和 R1 为了调试方便,填入与寄存器号相对应的 16进制数。
       代码清单 5-12(7) : 剩下的是 8 个需要手动加载到 CPU 寄存器的参数,为了调试方便填入与寄存器号相对应的 16 进制数。
       代码清单 5-12(8) :返回栈指针 p_stk,这个时候 p_stk 指向剩余栈的栈顶。
       代码清单 5-9 (8): 将剩余栈的栈顶指针 p_sp 保存到任务控制块 TCB 的第一个成员StkPtr中。
       代码清单 5-9 (9): 将任务堆栈的大小保存到任务控制块 TCB 的成员 StkSize 中。
       代码清单 5-9 (10): 函数执行到这里表示没有错误,即 OS_ERR_NONE。
       任务创建好之后,我们需要把任务添加到一个叫就绪列表的数组里面, 表示任务已经就绪,系统随时可以调度。 将任务添加到就绪列表的代码具体见代码清单 5-13。
       代码清单 5-13 将任务添加到就绪列表

/* 将任务加入到就绪列表 */
OSRdyList[0].HeadPtr = &Task1TCB;         (1)
OSRdyList[1].HeadPtr = &Task2TCB;         (2)

代码清单 5-13 (1)和(2):把任务 TCB 指针放到 OSRDYList 数组里面OSRDYList 是一个类型为 OS_RDY_LIST 的全局变量,在 os.h 中定义,具体见代码清单5-14。
代码清单 5-14 全局变量 OSRDYList 定义

 (1)       (2)              (3)
OS_EXT   OS_RDY_LIST    OSRdyList[OS_CFG_PRIO_MAX];

代码清单 5-14 (3): OS_CFG_PRIO_MAX 是一个定义,表示这个系统支持多少个优先级(刚开始暂时不支持多个优先级,往后章节会支持),目前这里仅用来表示这个就绪列表可以存多少个任务的 TCB 指针。具体的宏在 os_cfg.h(os_cfg.h 第一次使用需要自行在文件夹 uCOS-III\Source 中新建并添加到工程的 uC/OS-III Source 组) 中定义,具体见代码清单 5-15。
代码清单 5-15 OS_CFG_PRIO_MAX 宏定义

#ifdef OS_GLOBALS
#define OS_EXT
#else
#define OS_EXT extern
#endif

代码清单 5-17:该段代码的意思是,如果没有定义 OS_GLOBALS 这个宏,那么OS_EXTI就为空,否则就为 extern。
       在 uC/OS-III 中,需要使用很多全局变量,这些全局变量都在 os.h 这个头文件中定义,但是 os.h 会被包含进很多的文件中,那么编译的时候, os.h 里面定义的全局变量就会出现重复定义的情况,而我们要的只是 os.h 里面定义的全局变量只定义一次,其它包含 os.h 头文件的时候只是声明。有人说,那我可以加 extern,那你告诉我怎么加?
       通常我们的做法都是在 C 文件里面定义全局变量,然后在头文件里面加 extern 声明,哪里需要使用就在哪里加 extern 声明。但是 uC/OS-III中,文件非常多,这种方法可行,但不现实。所以就有了现在在 os.h 头文件中定义全局变量,然后在 os.h 文件的开头加上代码清单 5-17 的宏定义的方法。 但是到了这里还没成功, uC/OS-III 再另外新建了一个 os_var.c(os_var.c 第一次使用需要自行在文件夹 uCOS-III\Source 中新建并添加到工程的 uC/OS-IIISource 组) 的文件,在里面包含 os.h,且只在这个文件里面定义 OS_GLOBALS 这个宏,具体见代码清单 5-18。
代码清单 5-18 os_var.c 文件内容

#define OS_GLOBALS
#include "os.h"

   经过这样处理之后, 在编译整个工程的时候,只有 var.c 里面的 os.h 的 OS_EXTI 才会被替换为空,即变量的定义,其它包含 os.h 的文件因为没有定义 OS_GLOBAS 这个宏,则OS_EXTI会被替换成 extern,即变成了变量的声明。这样就实现了在头文件中定义变量。
       在 uC/OS-III 中,将任务添加到就绪列表其实是在 OSTaskCreate()函数中完成的。每当任务创建好就把任务添加到就绪列表,表示任务已经就绪。只是目前这里的就绪列表的实现还是比较简单,不支持优先级,不支持双向链表,只是简单的将任务控制块放到就绪列表的数组里面。后面会有独立的章节来讲解就绪列表,等我们完善就绪列表之后,再把这部分的操作放回 OSTaskCreate()函数里面。

5.4 OS 系统初始化:

OS 系统初始化一般是在硬件初始化完成之后来做的,主要做的工作就是初始化uC/OS-III 中定义的全局变量。 OSInit()函数在文件 os_core.c(os_core.c 第一次使用需要自行在文件夹 uCOS-III\Source 中新建并添加到工程的 uC/OS-III Source 组)中定义,具体实现见代码清单 5-19。
代码清单 5-19 OSInit()函数

void OSInit (OS_ERR *p_err)
{
OSRunning = OS_STATE_OS_STOPPED;  (1)
OSTCBCurPtr = (OS_TCB *)0;        (2)
OSTCBHighRdyPtr = (OS_TCB *)0;    (3)
OS_RdyListInit();                 (4)
*p_err = OS_ERR_NONE;             (5)
}

代码清单 5-19(1): 系统用一个全局变量 OSRunning 来指示系统的运行状态,刚开始系统初始化的时候,默认为停止状态,即 OS_STATE_OS_STOPPED。
       代码清单 5-19(2): 全局变量 OSTCBCurPtr 是系统用于指向当前正在运行的任务的TCB指针,在任务切换的时候用得到。
       代码清单 5-19(3): 全局变量 OSTCBHighRdyPtr 用于指向就绪任务中优先级最高的任务的 TCB,在任务切换的时候用得到。本章暂时不支持优先级,则用于指向第一个运行的任务的 TCB。
       代码清单 5-19(4): OS_RdyListInit()用于初始化全局变量 OSRdyList[], 即初始化就绪列表。 OS_RdyListInit()在 os_core.c文件中定义,具体实现见代码清单 5-20
代码清单 5-20 OS_RdyListInit() 函数

void OS_RdyListInit(void)
{
OS_PRIO i;
OS_RDY_LIST *p_rdy_list;
for ( i=0u; i<OS_CFG_PRIO_MAX; i++ ) {
p_rdy_list = &OSRdyList[i];
p_rdy_list->HeadPtr = (OS_TCB *)0;
p_rdy_list->TailPtr = (OS_TCB *)0;
}
}

代码清单 5-19(5): 代码运行到这里表示没有错误,即 OS_ERR_NONE。
       代码清单 5-19 中的全局变量 OSTCBCurPtr 和 OSTCBHighRdyPtr 均在 os.h中定义,具体见代码清单 5-21。 OS_STATE_OS_STOPPED 这个表示系统运行状态的宏也在 os.h 中定义,具体见代码清单 5-22。
       代码清单 5-21 OSInit()函数中出现的全局变量的定义

#define OS_STATE_OS_STOPPED (OS_STATE)(0u)
#define OS_STATE_OS_RUNNING (OS_STATE)(1u)

5.5 启动系统:

任务创建好,系统初始化完毕之后,就可以开始启动系统了。 系统启动函数 OSStart()在 os_core.c中定义,具体实现见代码清单 5-23。
代码清单 5-23 OSStart() 函数

void OSStart (OS_ERR *p_err)
{
if ( OSRunning == OS_STATE_OS_STOPPED ) { (1)
/* 手动配置任务 1 先运行 */
OSTCBHighRdyPtr = OSRdyList[0].HeadPtr;   (2)
/* 启动任务切换,不会返回 */
OSStartHighRdy();                         (3)
/* 不会运行到这里,运行到这里表示发生了致命的错误 */
*p_err = OS_ERR_FATAL_RETURN;
} else {
*p_err = OS_STATE_OS_RUNNING;
}
}

代码清单 5-23(1): 系统是第一次启动的话, if 肯定为真,则继续往下运行。
       代码清单 5-23(2): OSTCBHIghRdyPtr 指向第一个要运行的任务的 TCB。因为暂时不支持优先级,所以系统启动时先手动指定第一个要运行的任务。
       代码清单 5-23(3): OSStartHighRdy()用于启动任务切换, 即配置 PendSV 的优先级为最低,然后触发 PendSV异常,在 PendSV异常服务函数中进行任务切换。该函数不再返回,在文件 os_cpu_a.s(os_cpu_a.s 第一次使用需要自行在文件夹 uCOS-III\Ports 中新建并添加到工程的 uC/OS-III Ports 组) 中定义,由汇编语言编写,具体实现见代码清单 5-24。os_cpu_a.s文件中涉及到的 ARM汇编指令的用法具体见表格 5-1。
表格 5-1 常用的 ARM 汇编指令讲解

代码清单 5-24 OSStartHighRdy() 函数

;*******************************************************************
; 开始第一次上下文切换
; 1、配置 PendSV 异常的优先级为最低
; 2、在开始第一次上下文切换之前,设置 psp=0
; 3、触发 PendSV 异常,开始上下文切换
;*******************************************************************
OSStartHighRdy
LDR R0, = NVIC_SYSPRI14 ; 设置 PendSV 异常优先级为最低 (1)
LDR R1, = NVIC_PENDSV_PRI
STRB R1, [R0]
MOVS R0, #0 ;设置 psp 的值为 0,开始第一次上下文切换    (2)
MSR PSP, R0
LDR R0, =NVIC_INT_CTRL ; 触发 PendSV 异常             (3)
LDR R1, =NVIC_PENDSVSET
STR R1, [R0]
CPSIE I ; 使能总中断, NMI 和 HardFault 除外           (4)
OSStartHang
B OSStartHang ; 程序应永远不会运行到这里

代码清单 5-24 中涉及到的 NVIC_INT_CTRL、 NVIC_SYSPRI14、 NVIC_PENDSV_PRI和 NVIC_PENDSVSET 这四个常量在 os_cpu_a.s 的开头定义,具体见代码清单 5-25,有关这四个常量的含义看代码注释即可。
       代码清单 5-25 NVIC_INT_CTRL、 NVIC_SYSPRI14、 NVIC_PENDSV_PRI 和 NVIC_PENDSVSET 常量定义

;********************************************************************
; 常量
;********************************************************************
;--------------------------------------------------------------------
;有关内核外设寄存器定义可参考官方文档: STM32F10xxx Cortex-M3 programming manual
;系统控制块外设 SCB 地址范围: 0xE000ED00-0xE000ED3F
;--------------------------------------------------------------------
NVIC_INT_CTRL EQU 0xE000ED04 ; 中断控制及状态寄存器 SCB_ICSR。
NVIC_SYSPRI14 EQU 0xE000ED22 ; 系统优先级寄存器 SCB_SHPR3:
; bit16~23
NVIC_PENDSV_PRI EQU 0xFF ; PendSV 优先级的值(最低)。
NVIC_PENDSVSET EQU 0x10000000 ; 触发 PendSV 异常的值 Bit28: PENDSVSET

代码清单 5-24(1):配置 PendSV的优先级为 0XFF,即最低。 在 uC/OS-III中,上下文切换是在 PendSV 异常服务程序中执行的,配置 PendSV的优先级为最低,从而消灭了在中断服务程序中执行上下文切换的可能。
       代码清单 5-24(2): 设置 PSP 的值为 0,开始第一个任务切换。在任务中,使用的栈指针都是 PSP,后面如果判断出 PSP 为 0,则表示第一次任务切换。
       代码清单 5-24(3): 触发 PendSV 异常,如果中断使能且有编写 PendSV 异常服务函数的话,则内核会响应 PendSV异常,去执行 PendSV异常服务函数。
       代码清单 5-24(4): 开中断,因为有些用户在 main 函数开始会先关掉中断,等全部初始化完成后,在启动 OS 的时候才开中断。 为了快速地开关中断, CM3 专门设置了一条CPS 指令,有 4 种用法,具体见代码清单 5-26。
代码清单 5-26 CPS 指令用法

CPSID I ;PRIMASK=1 ;关中断
CPSIE I ;PRIMASK=0 ;开中断
CPSID F ;FAULTMASK=1 ;关异常
CPSIE F ;FAULTMASK=0 ;开异常

代码清单 5-26 中 PRIMASK和 FAULTMAST是 CM3 里面三个中断屏蔽寄存器中的两个,还有一个是 BASEPRI,有关这三个寄存器的详细用法见表格 5-2。
表格 5-2 CM3 中断屏蔽寄存器组描述

5.6 任务切换:

当调用 OSStartHighRdy()函数,触发 PendSV 异常后,就需要编写 PendSV 异常服务函数,然后在里面进行任务切换。 PendSV 异常服务函数具体见代码清单 5-27。 PendSV 异常服务函数名称必须与启动文件里面向量表中 PendSV 的向量名一致,如果不一致则内核是响应不了用户编写的 PendSV 异常服务函数的,只响应启动文件里面默认的 PendSV异常服务函数。启动文件里面为每个异常都编写好默认的异常服务函数,函数体都是一个死循环,当你发现代码跳转到这些启动文件里面默认的异常服务函数的时候,就要检查下异常函数名称是否写错了,没有跟向量表里面的一致。 PendSV_Handler 函数里面涉及到的 ARM 汇编指令的讲解具体见表格 5-3。
代码清单 5-27 PendSV 异常服务函数

;***********************************************************************
; PendSVHandler 异常
;***********************************************************************
PendSV_Handler
; 关中断, NMI 和 HardFault 除外,防止上下文切换被中断
CPSID I                                                           (1)
; 将 psp 的值加载到 R0
MRS R0, PSP                                                       (2)
; 判断 R0,如果值为 0 则跳转到 OS_CPU_PendSVHandler_nosave
; 进行第一次任务切换的时候, R0 肯定为 0
CBZ R0, OS_CPU_PendSVHandler_nosave                               (3)
;-----------------------一、保存上文-----------------------------
; 任务的切换,即把下一个要运行的任务的堆栈内容加载到 CPU 寄存器中
;--------------------------------------------------------------
; 在进入 PendSV 异常的时候,当前 CPU 的 xPSR, PC(任务入口地址),
; R14, R12, R3, R2, R1, R0 会自动存储到当前任务堆栈,
; 同时递减 PSP 的值,随便通过 代码: MRS R0, PSP 把 PSP 的值传给 R0
; 手动存储 CPU 寄存器 R4-R11 的值到当前任务的堆栈
STMDB R0!, {R4-R11}                                               (15)
; 加载 OSTCBCurPtr 指针的地址到 R1,这里  LDR 属于伪指令
LDR R1, = OSTCBCurPtr                                             (16)
; 加载 OSTCBCurPtr 指针到 R1,这里 LDR 属于 ARM 指令
LDR R1, [R1]                                                      (17)
; 存储 R0 的值到 OSTCBCurPtr->OSTCBStkPtr,这个时候 R0 存的是任务空闲栈的栈顶
STR R0, [R1]                                                      (18)
;-----------------------二、切换下文-----------------------------
; 实现 OSTCBCurPtr = OSTCBHighRdyPtr
; 把下一个要运行的任务的堆栈内容加载到 CPU 寄存器中
;--------------------------------------------------------------
OS_CPU_PendSVHandler_nosave                                       (4)
; 加载 OSTCBCurPtr 指针的地址到 R0,这里 LDR 属于伪指令
LDR R0, = OSTCBCurPtr                                             (5)
; 加载 OSTCBHighRdyPtr 指针的地址到 R1,这里 LDR 属于伪指令
LDR R1, = OSTCBHighRdyPtr                                         (6)
; 加载 OSTCBHighRdyPtr 指针到 R2,这里 LDR 属于 ARM 指令
LDR R2, [R1]                                                      (7)
; 存储 OSTCBHighRdyPtr 到 OSTCBCurPtr
STR R2, [R0]                                                      (8)
; 加载 OSTCBHighRdyPtr 到 R0
LDR R0, [R2]                                                      (9)
; 加载需要手动保存的信息到 CPU 寄存器 R4-R11
LDMIA R0!, {R4-R11}                                               (10)
; 更新 PSP 的值,这个时候 PSP 指向下一个要执行的任务的堆栈的栈底
;(这个栈底已经加上刚刚手动加载到 CPU 寄存器 R4-R11 的偏移)
MSR PSP, R0                                                       (11)
; 确保异常返回使用的堆栈指针是 PSP,即 LR 寄存器的位 2 要为 1
ORR LR, LR, #0x04                                                 (12)
; 开中断
CPSIE I                                                           (13)
; 异常返回,这个时候任务堆栈中的剩下内容将会自动加载到 xPSR,
; PC(任务入口地址), R14, R12, R3, R2, R1, R0(任务的形参)
; 同时 PSP 的值也将更新,即指向任务堆栈的栈顶。
; 在 STM32 中,堆栈是由高地址向低地址生长的。
BX LR                                                             (14)

代码清单 5-27 PendSV 异常服务中主要完成两个工作一是保存上文,即保存当前正在运行的任务的环境参数;二是切换下文,即把下一个需要运行的任务的环境参数从任务堆栈中加载到 CPU 寄存器,从而实现任务的切换。 接下来具体讲解下代码清单 5-27 每句代码的含义。
        代码清单 5-27 PendSV 异常服务中 用到了 OSTCBCurPtr 和 OSTCBHighRdyPtr 这两个全局变量,这两个全局变量在 os.h 中定义,要想在汇编文件 os_cpu_a.s 中使用,必须将这两个全局变量导入到 os_cpu_a.s中,具体如何导入见代码清单 5-28。
代码清单 5-28 导入 OSTCBCurPtr 和 OSTCBHighRdyPtr 到 os_cpu_a.s

;*******************************************************************
; 全局变量&函数
;*******************************************************************
IMPORT OSTCBCurPtr ; 外部文件引人的参考 (1)
IMPORT OSTCBHighRdyPtr
EXPORT OSStartHighRdy ; 该文件定义的函数 (2)
EXPORT PendSV_Handl

代码清单 5-28 (1): 使 用 IMPORT 关 键字 将 os.h 中 的 OSTCBCurPtr 和OSTCBHighRdyPtr 这两个全局变量导入到该汇编文件,从而该汇编文件可以使用这两个变量。如果是函数也可以使用 IMPORT导入的方法。
       代码清单 5-28 (2):使用 EXPORT 关键字导出该汇编文件里面的 OSStartHighRdy和PendSV_Handler 这两个函数,让外部文件可见。除了使用 EXPORT导出外,还要在某个 C的头文件里面声明下这两个函数(在 uC/OS-III中是在 os_cpu.h中声明) ,这样才可以在 C文件里面调用这两个函数。
       代码清单 5-27(1): 关中断, NMI 和 HardFault 除外,防止上下文切换被中断。在上下文切换完毕之后,会重新开中断。
       代码清单 5-27(2): 将 PSP 的值加载到 R0 寄存器。 MRS 是 ARM 32 位数据加载指令,功能是加载特殊功能寄存器的值到通用寄存器。
       代码清单 5-27(3): 判断 R0,如果值为 0 则跳转到 OS_CPU_PendSVHandler_nosave。进行第一次任务切换的时候, PSP 在 OSStartHighRdy初始化为 0,所以这个时候 R0肯定为0,则跳转到 OS_CPU_PendSVHandler_nosave。 CBZ 是 ARM 16 位转移指令,用于比较,结果为 0则跳转。
       代码清单 5-27(4): 当第一次任务切换的时候,会跳转到这里运行。当执行过一次任务切换之后,则顺序执行到这里。这个标号以后的内容属于下文切换。
       代码清单 5-27(5): 加载 OSTCBCurPtr 指针的地址到 R0。在 ARM汇编中,操作变量都属于间接操作,即要先获取到这个变量的地址。 这里 LDR 属于伪指令,不是 ARM 指令。 举例: LDR Rd, = label, 如果 label 是立即数,那 Rd 等于立即数, 如果 label 是一个标识符,比如指针,那存到 Rd 的就是 label 这个标识符的地址。
       代码清单 5-27(6): 加载 OSTCBHighRdyPtr 指针的地址到 R1,这里 LDR也属于伪指令。
       代码清单 5-27(7): 加载 OSTCBHighRdyPtr 指针到 R2,这里 LDR 属于 ARM 指令。
       代码清单 5-27(8): 存储 OSTCBHighRdyPtr 到 OSTCBCurPtr,实现下一个要运行的任务的 TCB 存储到 OSTCBCurPtr。

代码清单 5-27(9): 加载 OSTCBHighRdyPtr 到 R0。 TCB 中第一个成员是栈指针 StkPtr,所以这个时候 R0 等于 StkPtr,后续操作任务栈都是通过操作 R0 来实现,不需要操作 StkPtr。
       代码清单 5-27(10): 将任务栈中需要手动加载的内容加载到 CPU 寄存器 R4-R11,同时会递增 R0,让 R0 指向空闲栈的栈顶。 LDMIA 中的 I 是 increase 的缩写, A 是 after 的缩小,R0 后面的感叹号“!”表示会自动调节 R0 里面存的指针。 当任务被创建的时候,任务的栈会被初始化,初始化的流程是:先让栈指针 StkPtr 指向栈顶,然后从栈顶开始依次存储异常退出时会自动加载到 CPU 寄存器的值和需要手动加载到 CPU 寄存器的值,具体代码实现见代码清单 5-12 OSTaskStkInit()函数, 栈空间的分布情况具体见图 5-3。 当把需要手动加载到 CPU的栈内容加载完毕之后,栈空间的分布图和栈指针指向具体见图 5-4,注意这个时候 StkPtr不变,变的是 R0。

代码清单 5-27(11): 更新 PSP 的值,这个时候 PSP 与图 5-4 中 R0 的指向一致。
       代码清单 5-27(12): 设置 LR 寄存器的位 2 为 1,确保异常退出时使用的堆栈指针是PSP。当异常退出后,就切换到就绪任务中优先级最高的任务继续运行。
       代码清单 5-27(13): 开中断。 上下文切换已经完成了四分之三,剩下的就是异常退出时自动保存的部分。
       代码清单 5-27(14): 异常返回,这个时候任务堆栈中的剩下内容将会自动加载到xPSR, PC(任务入口地址), R14, R12, R3, R2, R1, R0(任务的形参) 这些寄存器。同时 PSP 的值也将更新,即指向任务堆栈的栈顶。 这样就切换到了新的任务。 这个时候栈空间的分布具体见图 5-5。

代码清单 5-27(15): 手动存储 CPU 寄存器 R4-R11 的值到当前任务的堆栈。 当异常发生, 进入 PendSV 异常服务函数的时候,当前 CPU 寄存器 xPSR, PC(任务入口地址),R14, R12, R3, R2, R1, R0 会自动存储到当前的任务堆栈,同时递减 PSP 的值, 这个时候当前任务的堆栈空间分布具体见图 5-6。 当执行 STMDB R0!, {R4-R11}代码后,当前任务的栈空间分布图具体见。

代码清单 5-27(16): 加载 OSTCBCurPtr 指针的地址到 R1,这里 LDR属于伪指令。
      代码清单 5-27(17): 加载 OSTCBCurPtr 指针到 R1,这里 LDR属于 ARM指令。
      代码清单 5-27(18): 存储 R0 的值到 OSTCBCurPtr->OSTCBStkPtr,这个时候 R0 存的是任务空闲栈的栈顶。到了这里,上文的保存就总算完成。这个时候当前任务的堆栈空间分布和栈指针指向具体见图 5-8。

5.7 main 函数:

main 函数在文件 app.c中编写,其中 app.c 文件中的所有代码具体见代码清单 5-29。
代码清单 5-29 app.c 文件中的代码

/*
*******************************************************************
* 包含的头文件
*******************************************************************
*/
#include "os.h"
#include "ARMCM3.h"
/*
*******************************************************************
* 宏定义
*******************************************************************
*/
/*
*******************************************************************
* 全局变量
*******************************************************************
*/
uint32_t flag1;
uint32_t flag2;
/*
*******************************************************************
* TCB & STACK & 任务声明
*******************************************************************
*/
#define TASK1_STK_SIZE 20
#define TASK2_STK_SIZE 20
static CPU_STK Task1Stk[TASK1_STK_SIZE];
static CPU_STK Task2Stk[TASK2_STK_SIZE];
static OS_TCB Task1TCB;
static OS_TCB Task2TCB;
void Task1( void *p_arg );
void Task2( void *p_arg );
/*
*******************************************************************
* 函数声明
*******************************************************************
*/
void delay(uint32_t count);
/*
*******************************************************************
* main 函数
*******************************************************************
*/
/*
* 注意事项: 1、该工程使用软件仿真, debug 需选择 Ude Simulator
* 2、在 Target 选项卡里面把晶振 Xtal(Mhz)的值改为 25,默认是 12,
* 改成 25 是为了跟 system_ARMCM3.c 中定义的__SYSTEM_CLOCK 相同,
* 确保仿真的时候时钟一致
*/
int main(void)
{
OS_ERR err;
/* 初始化相关的全局变量 */
OSInit(&err);
/* 创建任务 */
OSTaskCreate ((OS_TCB*) &Task1TCB,
(OS_TASK_PTR ) Task1,
(void *) 0,
(CPU_STK*) &Task1Stk[0],
(CPU_STK_SIZE) TASK1_STK_SIZE,
(OS_ERR *) &err);
OSTaskCreate ((OS_TCB*) &Task2TCB,
(OS_TASK_PTR ) Task2,
(void *) 0,
(CPU_STK*) &Task2Stk[0],
(CPU_STK_SIZE) TASK2_STK_SIZE,
(OS_ERR *) &err);
/* 将任务加入到就绪列表 */
OSRdyList[0].HeadPtr = &Task1TCB;
OSRdyList[1].HeadPtr = &Task2TCB;
/* 启动 OS,将不再返回 */
OSStart(&err);
}
/*
*******************************************************************
* 函数实现
*******************************************************************
*/
/* 软件延时 */
void delay (uint32_t count)
{
for (; count!=0; count--);
}
/* 任务 1 */
void Task1( void *p_arg )
{
for ( ;; ) {
flag1 = 1;
delay( 100 );
flag1 = 0;
delay( 100 );
/* 任务切换,这里是手动切换 */
OSSched();
}
}
/* 任务 2 */
void Task2( void *p_arg )
{
for ( ;; ) {
flag2 = 1;
delay( 100 );
flag2 = 0;
delay( 100 );
/* 任务切换,这里是手动切换 */
OSSched();
}
}

代码清单 5-29 中的所有代码在本小节之前都有循序渐进的讲解,这里这是融合在一起放在 main 函数中。 其实现在 Task1 和 Task2 并不会真正的自动切换,而是在各自的函数体里面加入了 OSSched()函数来实现手动切换, OSSched()函数的实现具体见代码清单 5-30。

/* 任务切换,实际就是触发 PendSV 异常,然后在 PendSV 异常中进行上下文切换 */
void OSSched (void)
{
if ( OSTCBCurPtr == OSRdyList[0].HeadPtr ) {
OSTCBHighRdyPtr = OSRdyList[1].HeadPtr;
} else {
OSTCBHighRdyPtr = OSRdyList[0].HeadPtr;
}
OS_TASK_SW();
}

OSSched()函数的调度算法很简单,即如果当前任务是任务 1,那么下一个任务就是任务 2,如果当前任务是任务 2,那么下一个任务就是任务 1,然后再调用 OS_TASK_SW()函数触发 PendSV异常,然后在 PendSV 异常里面实现任务的切换。在往后的章节中,我们将继续完善,加入 SysTick 中断,从而实现系统调度的自动切换。 OS_TASK_SW()函数其实是一个宏定义,具体是往中断及状态控制寄存器 SCB_ICSR 的位 28(PendSV 异常使能位)写入 1,从而触发 PendSV 异常。 OS_TASK_SW()函数在 os_cpu.h 文件中实现,os_cpu.h(os_cpu.h 第一次使用需要自行在文件夹 uC-CPU 中新建并添加到工程的 uC-CPU组) 文件内容具体见代码清单 5-31。
代码清单 5-31 os_cpu.h 文件代码清单

#ifndef OS_CPU_H
#define OS_CPU_H
/*
*******************************************************************
* 宏定义
*******************************************************************
*/
#ifndef NVIC_INT_CTRL
/* 中断控制及状态寄存器 SCB_ICSR */
#define NVIC_INT_CTRL *((CPU_REG32 *)0xE000ED04)
#endif
#ifndef NVIC_PENDSVSET
/* 触发 PendSV 异常的值 Bit28: PENDSVSET */
#define NVIC_PENDSVSET 0x10000000
#endif
/* 触发 PendSV 异常 */
#define OS_TASK_SW() NVIC_INT_CTRL = NVIC_PENDSVSET
/* 触发 PendSV 异常 */
#define OSIntCtxSw() NVIC_INT_CTRL = NVIC_PENDSVSET
/*
*******************************************************************
* 函数声明
*******************************************************************
*/
void OSStartHighRdy(void); /* 在 os_cpu_a.s 中实现 */
void PendSV_Handler(void); /* 在 os_cpu_a.s 中实现 */
#endif /* OS_CPU_H */

5.8 实验现象:

本章代码讲解完毕,接下来是软件调试仿真,具体过程见图 5-9、 图 5-10、 图 5-11、图 5-12 和图 5-13。

至此,本章讲解完毕。 但是,只是把本章的内容看完,然后再仿真看看波形是远远不够的, 应该是把任务的堆栈、 TCB、 OSTCBCurPtr和 OSTCBHighRdyPtr 这些变量统统添加到观察窗口,然后单步执行程序,看看这些变量是怎么变化的。 特别是任务切换时, CPU寄存器、任务堆栈和 PSP 这些是怎么变化的,让机器执行代码的过程在自己的脑子里面过一遍。 图 5-14就是我在仿真调试时的观察窗口。

从0到1教你写UCOS-III 第五部分:任务的定义与任务切换的实现相关推荐

  1. 从0到1教你写UCOS-III 第十二部分:实现时基列表

    从本章开始,我们在 OS 中加入时基列表,时基列表是跟时间相关的, 处于延时的任务和等待事件有超时限制的任务都会从就绪列表中移除,然后插入到时基列表. 时基列表在 OSTimeTick 中更新,如果任 ...

  2. 程序之家系列教程之手把手教你写熊猫烧香病毒专杀工具

    (作者:chenhui530,论坛 http://chenhui530.com ) 前言       经过去年和熊猫烧香.威金等病毒的"斗争",我也累了,"程序之家病毒专 ...

  3. 猫哥教你写爬虫 046--协程-实践-吃什么不会胖

    吃什么不会胖? 低热量食物 食物的数量有千千万,如果我们要爬取食物热量的话,这个数据量必然很大. 使用多协程来爬取大量的数据是非常合理且明智的选择 如果我们要爬取的话,那就得选定一个有存储食物热量信息 ...

  4. 猫哥教你写爬虫 006--条件判断和条件嵌套

    流程控制 复仇者联盟3-无限战争(搜集宝石) python里面, 不需要使用;来结尾, 因为python是使用换行来结束一行代码的 if判断, 没有{}, python使用缩进来表示层级关系 if.. ...

  5. 手把手教你写一个生成对抗网络

    成对抗网络代码全解析, 详细代码解析(TensorFlow, numpy, matplotlib, scipy) 那么,什么是 GANs? 用 Ian Goodfellow 自己的话来说: " ...

  6. python k线合成_手把手教你写一个Python版的K线合成函数

    手把手教你写一个Python版的K线合成函数 在编写.使用策略时,经常会使用一些不常用的K线周期数据.然而交易所.数据源又没有提供这些周期的数据.只能通过使用已有周期的数据进行合成.合成算法已经有一个 ...

  7. cmd管道无法接收特定程序返回值_渗透不会反弹shell?来教你写一个cmd的shell

    渗透不会反弹shell?来教你写一个cmd的shell 包含的库: #include #include #include #include #include #pragma comment(lib, ...

  8. 手把手教你写Linux I2C设备驱动

    手把手教你写Linux I2C设备驱动 标签:Linux 设备 驱动 详解 i2c 原创作品,允许转载,转载时请务必以超链接形式标明文章 原始出处 .作者信息和本声明.否则将追究法律责任.http:/ ...

  9. 手把手教你写高质量Android技术博客,画图工具,录像工具,Markdown写法

    前言 作为程序员,写博客是一件很有意义的事情,可以加深自己对技术的理解,可以结交更多的朋友,记录自己的技术轨迹,而且分享可以让更多的人从中受益,独乐乐不如众乐乐嘛. 但是要写好博客也不是件容易的事,一 ...

最新文章

  1. springboot与分布式(zookeeper+dubbo)
  2. pat1049. Counting Ones (30)
  3. 微软宣布ASP.NET Core 2.0正式支持OData标准
  4. SpringMVC从入门到精通之第一章_慕课文章
  5. Leetcode-SingleNumberII
  6. Windows下Java调用BAT批处理不弹出cmd窗口
  7. 克罗谈投资策略03_你所期望的赌博方式
  8. 用JavaScript怎么写Windows的状态栏
  9. VL1_四选一多路器(完整RTL、Testbench和覆盖率)
  10. ERP实施-有色金属-铜冶炼
  11. 最全电力电子仿真matlab/simulink仿真 单相全桥/半桥整流仿真 单相半波全波仿真
  12. windows正版系统下载地址
  13. Avalon总线基础介绍(数据手册版)之Avalon-MM接口
  14. SAP 打开或关闭财务账期和物料账期等事物代码清单-OB52/MMPV/MMRV/OKP1/1KEF
  15. C语言程序设计基础知识——谭浩强版
  16. 你以为程序员就是敲代码吗?
  17. CASS横断面成果转纬地格式(txt)
  18. 解决小程序安卓设备android可以播放在线网络视频 ,苹果ios设备无法播放视频 ,
  19. 企业必须了解的商标注册常见问题及避雷小技巧
  20. Java(if, else,else if)

热门文章

  1. PS 学习笔记 19-钢笔工具组
  2. DevExpress:带计算器功能的文本框CalcEdit
  3. python随机数列表变成字符串_第五天:python字符串和列表
  4. css实现背景色渐变效果
  5. 红盟域名第三方域名管理漏洞公告————【Badboy】
  6. nginx实现ip端口转发_配置Nginx实现端口转发
  7. 自然语言处理中的embeddings
  8. Unity3d 分辨率 注册表设定
  9. ps使用时新建画布一闪而过,或者导入照片一闪而过就消失的情况
  10. Windows Server 2019 安装 .net framework 3.5安装失败