1. 前言
setjmp
和longjmp
是C语言标准库头文件中提供的函数。它们的功能是实现非局部跳转,可以在程序的不同位置之间进行跳转,类似于goto
语句的扩展。这种非局部跳转的能力为我们构建查询式协作多任务系统提供了基础。
实现前先简单了解一下相关知识,方便后续开展实现。
跳转函数
setjmp
函数:用于保存当前程序状态,创建一个可以供后续longjmp
函数跳转的上下文环境。longjmp
函数:实现了对保存的上下文环境的跳转操作。通过传递之前由setjmp
函数保存的jmp_buf
标识符,longjmp
函数会将程序的状态还原到对应的上下文环境,并且会返回到setjmp
处继续执行。- 在调用
setjmp
时,程序会记录当前的程序计数器、寄存器和堆栈等状态信息,并将这些信息保存在一个jmp_buf
结构中。同时,setjmp
函数返回0作为普通调用的返回值,并将jmp_buf
作为标识符存储起来。 - 不同平台的
jmp_buf
的类型定义不一样,因为不同平台的相关寄存器等不一样,因此占用的大小也不同。
- 在调用
栈
- 栈指针:每个任务在运行时都有一个栈指针,指向其栈的顶部。任务切换时,需要保存这个指针(
jmp_buf
会保存),以便在任务恢复时能够正确访问该任务的栈数据。 - 局部变量和返回地址:栈用于存储任务的局部变量、函数参数和返回地址。在上下文切换时,这些信息也需要被保存,以确保任务能够在恢复时继续执行。
- 独立栈空间:每个任务都有自己的栈,确保任务之间的局部变量和状态不会相互干扰。这种隔离使得并发执行的任务能够独立运行,提高系统的稳定性。
汇编
这里需要用到一点点汇编,即设置栈顶的位置,不同平台使用的汇编不一样,这里可以在网上查到或者提供的demo中也能找到,只需要一条语句即可。
如 :
- x86 平台:
#define COT_OS_SET_STACK(p) __asm__ volatile("mov %0, %%rsp" : : "r" (p) : "memory");
- stm32
#define COT_OS_SET_STACK(p) __set_MSP(p);
功能实现
利用setjmp
和longjmp
实现一个任务调度系统(协程),setjmp
用于保存当前程序的执行环境,而longjmp
用于跳转到之前保存的执行环境。
具体需要实现三个核心功能。
流程定义
创建任务
- 初始化任务相关变量:申请相关内存,后续储存任务栈信息等
- 保存新任务的入口环境:设置新任务栈顶指针后,保存该环境,方便后续任务启动时从这里开始执行
- 将新的任务添加到任务列表:任务调度使用
启动任务
- 保存当前启动函数的执行环境:当所有任务都结束后还可以跳转到这退出该函数
- 跳转到第一个任务函数的入口执行环境,开始执行任务
休眠任务
- 更新保存当前任务函数此时的执行环境:下次任务切换运行时可以恢复到当前位置继续往下执行
- 查询就绪任务并跳转到就绪任务函数的入口执行环境或者更新后的执行环境
流程图
任务函数的流程走向图:
代码实现
TCB等信息定义
typedef struct stTCB
{
uint8_t state;
uint32_t nextRunTime;
jmp_buf env;
cotOsTask_f pfnOsTaskEnter;
struct stTCB *pNext;
} TCB_t;
#define COMMON_TASK_INTI 0
#define COMMON_TASK_RUN 1
#define MAIN_TASK_INTI 0
#define MAIN_TASK_EXIT 1
#define TASK_STATUS_READY 0 // 就绪
#define TASK_STATUS_RUNNING 1 // 运行
#define TASK_STATUS_SUSPEND 2 // 挂起
#define TASK_STATUS_DELETED 3 // 删除
创建任务
在函数中,设置新的栈顶后,由于还需要函数中定义的变量,为了防止设置新的堆栈后相关变量生命周期失效,需要使用static
修饰定义,保证其生命周期。
cotOsTask_t cotOs_CreatTask(cotOsTask_f pfnOsTaskEnter, void *pStack, size_t stackSize)
{
// 防止设置新的堆栈后该变量生命周期失效
static TCB_t *s_pNewTCB = NULL;
static jmp_buf s_creatTaskEnv;
if (pStack == NULL || stackSize == 0)
{
return NULL;
}
s_pNewTCB = CreatTCB(&sg_OsInfo);
if (NULL == s_pNewTCB)
{
return NULL;
}
s_pNewTCB->pfnOsTaskEnter = pfnOsTaskEnter;
s_pNewTCB->pNext = NULL;
s_pNewTCB->state = TASK_STATUS_READY;
s_pNewTCB->nextRunTime = 0;
if (0 == setjmp(s_creatTaskEnv))
{
COT_OS_SET_STACK(((size_t)pStack + stackSize));
if (COMMON_TASK_INTI == setjmp(s_pNewTCB->env))
{
// 设置新的栈顶后记录创建任务的入口后返回原来的任务栈继续运行
longjmp(s_creatTaskEnv, 1);
}
else
{
// 任务入口位置
sg_OsInfo.pCurTCB->state = TASK_STATUS_RUNNING;
sg_OsInfo.pCurTCB->pfnOsTaskEnter(sg_OsInfo.pCurTCB->param);
sg_OsInfo.pCurTCB->state = TASK_STATUS_DELETED;
DestoryTask(&sg_OsInfo, sg_OsInfo.pCurTCB);
if (GetTaskNum(&sg_OsInfo) > 0)
{
JumpNextTask(&sg_OsInfo);
}
else
{
// 没有任务则返回到启动任务的位置,可以退出
longjmp(sg_OsInfo.env, MAIN_TASK_EXIT);
}
}
}
AddToTCBTaskList(&sg_OsInfo, s_pNewTCB);
return s_pNewTCB;
}
启动任务
启动任务,保存该位置的执行环境,方便所有任务退出后这里可以正常退出函数。
int cotOs_Start(void)
{
if (sg_OsInfo.pTCBList == NULL)
{
return -1;
}
int ret = setjmp(sg_OsInfo.env);
if (MAIN_TASK_INTI == ret)
{
sg_OsInfo.pCurTCB = sg_OsInfo.pTCBList;
longjmp(sg_OsInfo.pCurTCB->env, COMMON_TASK_RUN);
}
// 退出
return 0;
}
休眠任务
任务休眠,则更新当前执行环境,并查询可运行的函数进行跳转
void cotOs_Wait(uint32_t time)
{
sg_OsInfo.pCurTCB->nextRunTime = sg_OsInfo.pfnGetTimerMs() + time;
sg_OsInfo.pCurTCB->state = TASK_STATUS_SUSPEND;
if (COMMON_TASK_RUN != setjmp(sg_OsInfo.pCurTCB->env))
{
JumpNextTask(&sg_OsInfo);
}
}
功能扩展
上述实现了基本功能,要求每个任务都有自己独立的栈空间。
为了适应内存资源少的平台,可以增加共享栈的任务调度,即多个任务使用同一个栈空间运行各自的任务。
共享栈任务的核心有:
- 任务在运行时独享 该共享栈:单线程运行的,只有待该任务休眠时则释放该栈空间给到下一个该类型的任务独享运行。
- 每个任务都有自己的备用栈:主要用来储存任务休眠前时储存在共享栈的数据,不过该备用栈的大小较小,只需要几十或者上百字节即可。
- 使用其他独立栈切换共享栈任务:共享栈任务互相切换时,由于需要将备份栈的数据恢复到共享栈空间,为了防止破环当前任务切换使用的栈数据,需要跳转到独立的栈空间中进行数据恢复并切换。
- 轻量级任务:由于备用栈的空间较小,因此要求该类型任务尽量不在入口函数中定义局部变量(可以定义static修饰的变量,不会占用栈空间),同时只能在入口函数这一层中去休眠任务(在嵌套函数休眠,所使用的栈空间更多,那么需要备份的栈数据就更多)
代码实现
创建任务
- 初始化任务相关变量:申请相关内存,后续储存任务栈信息等
- 保存新任务的入口环境:设置新任务栈顶指针后,保存该环境,方便后续任务启 动时从这里开始执行
- 将新的任务添加到任务列表:任务调度使用
新增:
- 区分共享栈和独立栈的处理
cotOsTask_t cotOs_CreatTask(cotOsTask_f pfnOsTaskEnter, CotOSStackType_e eStackType, void *pStack, size_t stackSize)
{
// 防止设置新的堆栈后该变量生命周期失效
static TCB_t *s_pNewTCB = NULL;
static jmp_buf s_creatTaskEnv;
if (eStackType == COT_OS_UNIQUE_STACK && (pStack == NULL || stackSize == 0))
{
return NULL;
}
if (eStackType == COT_OS_SHARED_STACK && sg_OsInfo.sharedStackTop == 0)
{
return NULL;
}
s_pNewTCB = CreatTCB(&sg_OsInfo);
if (NULL == s_pNewTCB)
{
return NULL;
}
s_pNewTCB->pfnOsTaskEnter = pfnOsTaskEnter;
s_pNewTCB->pNext = NULL;
s_pNewTCB->state = TASK_STATUS_READY;
s_pNewTCB->nextRunTime = 0;
s_pNewTCB->pBakStack = eStackType == COT_OS_SHARED_STACK ? CreatTCBStack(&sg_OsInfo) : NULL;
// 这里先判断类型再保存环境,防止先保存环境后再去判断类型分别设置栈顶,导致入栈数据错乱引发异常问题
if (eStackType == COT_OS_UNIQUE_STACK)
{
if (0 == setjmp(s_creatTaskEnv))
{
COT_OS_SET_STACK(((size_t)pStack + stackSize));
if (COMMON_TASK_INTI == setjmp(s_pNewTCB->env))
{
longjmp(s_creatTaskEnv, 1);
}
else
{
RunTask(&sg_OsInfo);
}
}
}
else
{
if (s_pNewTCB->pBakStack == NULL)
{
DestroyTCB(&sg_OsInfo, sg_OsInfo.pCurTCB);
return NULL;
}
if (0 == setjmp(s_creatTaskEnv))
{
COT_OS_SET_STACK(sg_OsInfo.sharedStackTop);
if (COMMON_TASK_INTI == setjmp(s_pNewTCB->env))
{
longjmp(s_creatTaskEnv, 1);
}
else
{
RunTask(&sg_OsInfo);
}
}
}
AddToTCBTaskList(&sg_OsInfo, s_pNewTCB);
return s_pNewTCB;
}
启动任务
- 保存当前启动函数的执行环境:当所有任务都结束后还可以跳转到这退出该函数
- 跳转到第一个任务函数的入口执行环境,开始执行任务
新增:
- 共享栈任务需要运行时,先跳转到该位置,利用
main
主任务的未使用的栈空间进行任务切换(尽量充分利用未使用的栈空间),先将即将执行的共享栈任务备份数据恢复到共享栈上,然后跳转过去这里主要防止共享栈任务切换到下一个共享栈任务,还没切换时共享栈就被覆盖破坏导致程序运行异常的问题。
int cotOs_Start(void)
{
if (sg_OsInfo.pTCBList == NULL || sg_OsInfo.pfnGetTimerMs == NULL)
{
return -1;
}
int ret = setjmp(sg_OsInfo.env);
if (MAIN_TASK_INTI == ret)
{
sg_OsInfo.pCurTCB = sg_OsInfo.pTCBList;
longjmp(sg_OsInfo.pCurTCB->env, COMMON_TASK_RUN);
}
else if (MAIN_TASK_JUMP_SHARED_TASK == ret)
{
TcbMemcpy((uint8_t *)(sg_OsInfo.sharedStackTop - COT_OS_MAX_SHARED_BAK_STACK_SIZE),
sg_OsInfo.pCurTCB->pBakStack, COT_OS_MAX_SHARED_BAK_STACK_SIZE);
longjmp(sg_OsInfo.pCurTCB->env, COMMON_TASK_RUN);
}
return 0;
}
休眠任务
- 更新保存当前任务函数此时的执行环境:下次任务切换运行时可以恢复到当前位置继续往下执行
- 查询就绪任务并跳转到就绪任务函数的入口执行环境或者更新后的执行环境
新增:
- 查询前如果当前任务是共享栈任务,则先将栈空间保存到该任务的备份栈空间。
void cotOs_Wait(uint32_t time)
{
sg_OsInfo.pCurTCB->nextRunTime = sg_OsInfo.pfnGetTimerMs() + time;
sg_OsInfo.pCurTCB->pCondition = NULL;
sg_OsInfo.pCurTCB->state = TASK_STATUS_SUSPEND;
if (COMMON_TASK_RUN != setjmp(sg_OsInfo.pCurTCB->env))
{
if (sg_OsInfo.pCurTCB->pBakStack != NULL)
{
TcbMemcpy(sg_OsInfo.pCurTCB->pBakStack,
(uint8_t *)(sg_OsInfo.sharedStackTop - COT_OS_MAX_SHARED_BAK_STACK_SIZE), COT_OS_MAX_SHARED_BAK_STACK_SIZE);
}
JumpNextTask(&sg_OsInfo);
}
}
完整源码
至此,已完成一个任务调度系统的实现。
想看完整代码实现的,程序源码:查询协作式多任务系统
问题
当然,在实现过程中是基于x86的linux平台进行开发测试的,问题是少不了的,而且有些问题也是这个平台带来的,选用的是gcc编译。
这个问题在其他平台不一定存在,不过可以了解一下
编译优化问题
在x86平台开发测试时,使用gcc编译的时候,正常可以运行,后续打开优化选项(-O1
/ -O2
/ -O3
)后,就发现始终无法正常运行,出现运行错误。
*** longjmp causes uninitialized stack frame ***: terminated
一开始以为是代码写法问题,后续测试了一个简单也是如此,但是在一个在线的gcc编译开发网页上是可以正常跑通的,所以排除了代码写法问题。
当然,也尝试过在开启编译优化的时候对该问题不进行优化编译,比如在该源码中加上#pragma
的语句,防止这部分代码编译优化。
这样写后也没啥问题了!
#pragma GCC push_options
#pragma GCC optimize("O0")
#include <stdbool.h>
// 其他代码
....
#pragma GCC pop_options
但是,我开启编译优化的初衷就是希望能对代码编译优化,提高效率,所以该问题还得往下分析。况且要实现这个功能可通用,就尽量脱离不同平台或者编译器的依赖
中途也用了
volatile
关键词分别测试定义相关变量都无法解决。
首先缩小#pragma GCC push_options
的限定范围,从单个函数测试到整个文件,发现下面这样才行
#include <stdbool.h>
#pragma GCC push_options
#pragma GCC optimize("O0")
#include <setjmp.h>
#include "cot_os.h"
#pragma GCC pop_options
#include "cot_os_config.h"
// 其他代码
....
最后索性生成汇编对比,发现开启优化和不优化,即使缩小到只限定两个头文件不优化,相差还是很大的
也是反复测试查看对比了很久,终于发现其中一个问题,并经过测试,顺利解决!!!
就是longjmp
在开启编译优化后被替换成了__longjmp_chk
。
网上查询了该函数,给出的结果如下:
__longjmp_chk
和longjmp
都是用于恢复程序执行流的函数,但它们之间有一些细微的差异,尤其是在安全性和实现层面。longjmp
longjmp
是标准 C 函数,用于从调用setjmp
设置的保存点(即上下文)处恢复程序的执行。常见的使用场景是异常处理或退出深层嵌套的函数调用。
longjmp(env, val)
恢复到先前由setjmp
保存的上下文 env。- 它会将 val 设置为返回值,以指示恢复点。
longjmp
不会返回,而是直接跳转到之前保存的上下文。__longjmp_chk
__longjmp_chk
是 GNU C 库中提供的一个内部函数,通常用于替代longjmp
,并提供额外的安全检查。这个函数主要是在编译器或运行时环境中自动替换调用,以增强保护,防止栈溢出等潜在的安全漏洞。
- 它会执行和
longjmp
类似的功能,但会额外进行安全检查,确保上下文没有被破坏,尤其是检查栈指针和其他寄存器的状态。- 通常在使用了某些安全编译选项(如 FORTIFY_SOURCE)时,longjmp 会被替换为
__longjmp_chk
,以增强安全性。区别总结
- 实现层次:
__longjmp_chk
是longjmp
的安全版本,增加了额外的检查。- 应用场景:在常规程序中,直接使用
longjmp
即可,但在使用强化安全选项的编译环境下,可能会自动调用__longjmp_chk
。
简而言之,__longjmp_chk
是longjmp
的安全变种,主要用于防止栈溢出等安全问题,而longjmp
是原始的上下文恢复函数。
关于这个,在代码对比中我也发现了__printf
的安全版本 __printf_chk
。
由于实现的时候是改变了栈顶进行跳转的,因此相当于破坏了上下文,导致安全检查未通过。
解决方法:
只需要在GCC编译时增加-U_FORTIFY_SOURCE
编译选项即可。
GNU C 库中的一些内部函数(如
__longjmp_chk
)是为了加强安全性,通过编译器选项自动启用的。你可以通过禁用这些安全检查,强制使用标准的 C 函数。
-U_FORTIFY_SOURCE
将禁用编译器的保护机制,并防止longjmp
被替换为__longjmp_chk
。
gcc -U_FORTIFY_SOURCE -o my_program my_program.c
当然,我还是希望只针对这个函数,也尝试了网上给出的其他方法,当然都没有效果,包括但不限于-Wl,--wrap=__longjmp_chk
等方式。