单片机底层系列:C 库运行时——从 libspace 到多任务与中断安全

版权声明

  • 版权归作者 oku 所有,稀土掘金账号:oku62089。
  • 欢迎非商业转载、分享、引用,请注明作者 oku 和原文链接并保留本声明。
  • 禁止任何商业用途/牟利,侵权必究。
  • 文中图片水印 "稀土掘金oku62089" 为作者标识,请勿盗用。
  • ARM 官方文档相关内容版权归 ARM 所有。

概述

本文从 Keil 工程启动反汇编中的 __user_libspace() 调用出发,结合反汇编、源码和 FreeRTOS 实验,分析 ARM C Library 与 Newlib 如何保存、访问和保护库内部状态。通过 errno 读写追踪状态区的访问路径,复现多任务下的状态覆盖,并验证重写 __user_perthread_libspace() 隔离私有状态的效果;通过并发 malloc/free 实验复现 HardFault,结合异常栈、故障指令及 CFSR、BFAR 寄存器定位堆管理数据被破坏的问题,再通过加锁实验进行对照验证。在此基础上,对比两种库的状态管理和锁接口适配方式,进一步讨论中断中的状态覆盖、等锁死锁,以及屏蔽中断保护共享资源的局限。

前言

最近在分析一个 Keil 工程的启动反汇编时,我沿着 __rt_entry 的执行流程,在堆栈建立代码附近看到了一次对 __user_libspace() 的调用。

这个名字引起了我的注意,它返回了一个 RAM 地址,后面的代码ADD sp,sp,#0x60又与 96 字节有关的地址计算。这块空间是做什么的?

查阅资料后才知道,这是 Arm C 标准库使用的 libspace。默认情况下,库提供一块 96 字节的零初始化区域,用于保存部分内部状态。在 C 库初始化期间,这块区域还会被用作临时栈,这也解释了为什么会在启动阶段的堆栈建立代码附近看到它。

我们平时调用 printf、malloc,很少关心这些函数背后还需要哪些数据。实际上,除了参数和局部变量,库函数还可能依赖错误码、流对象、内存分配器的管理信息等状态。

当程序引入多任务或中断后,仅仅知道状态存在哪里还不够,哪些状态应该每个任务各有一份?哪些资源必须共享?共享资源又怎样避免被并发修改?这些问题关系到能否安全使用标准库,也能帮助我们理解为什么在中断中调用某些库函数可能造成数据损坏或死锁。

这篇文章从启动反汇编出发,结合实验和源码,以 ARM C Library (本文简称 ARM C)为主,Newlib 作对照,分析 C 库内部状态的存储、访问与保护方式。

本文的实验环境与源码版本如下:

  • IDE:Keil MDK 5.38.0
  • 编译器:ARM Compiler V6.19
  • C 语言标准:C99
  • 单片机:STM32F407VET6
  • FreeRTOS:FreeRTOS-Kernel-10.4.6
  • FreeRTOS 内存管理方案:heap_4.c
  • C 库配置:使用 ARM C Library,未启用 MicroLIB
  • 对照分析的 Newlib 源码版本:newlib-4.6.0

一、C 库函数也需要保存状态

有些函数只根据参数完成计算或处理,但许多 C 库函数还需要使用跨调用保留的状态,或者访问共享数据。例如:

  • printf 通过 stdout 输出,需要访问流的缓冲配置、当前位置等信息。
  • malloc 需要维护内存块的分配状态,以及查找、拆分和合并空闲块所需的管理信息。
  • errno 用于报告某些错误。例如,strtol 的转换结果超出可表示范围时,会设置 ERANGE。
  • strtok 需要记住上次分割的位置,才能在下一次调用时继续处理。
  • rand 需要维护伪随机序列的内部状态,srand 用于设置其初始状态。
  • localtime 返回指向内部结果对象的指针,后续相关调用可能覆盖该结果。
  • printf("%f") 底层需要大整数运算做浮点转十进制,中间结果需要缓存。
  • mbrtowc 通过 mbstate_t 保存多字节字符的转换状态,使一次未完成的转换能够在后续调用中继续。

这些数据的用途并不完全相同,有的是错误码,有的是算法状态,有的是结果对象,还有的是共享资源的管理信息。但它们都说明,调用一个库函数,可能不只是执行一段代码,还会读取或改变代码之外的数据。 那么,这些数据存在哪里?

具体取决于库的实现。部分数据会被集中组织在一个对象或一块内存中, 其他数据可能位于独立的全局对象、动态分配的内存,或者由调用者提供的对象中。那块内存区域或对象中也可能保存指向其他数据的指针。

在 Arm C(Keil)中 ,libspace 用来保存部分库状态,内部布局由 ARM 实现定义。默认情况下,它是一块 96 字节的零初始化区域,这与库版本和工程配置有关,库提供相应接口访问。

在 Newlib(GCC)中,部分状态被组织进 struct _reent。它同样不包含全部库数据。

接下来,我们分别看这两种库如何找到这些状态,以及在多个执行上下文之间怎样管理它们。

二、ARM C 如何管理库状态

在前面的上电流程分析中,我们在堆栈建立代码附近遇到了 libspace,C 标准库中的部分函数需要保存状态,例如错误码、软件浮点状态和 locale 信息。Arm C 就是使用这块内存保存其中一部分状态。

1. libspace 的访问接口

Arm C 提供了几个与 libspace 有关的接口:

接口 说明
__user_libspace() 旧的库私有状态访问接口
__user_perproc_libspace() 获取进程共享状态区的地址
__user_perthread_libspace() 获取当前线程状态区的地址

当需要访问状态的库函数被调用时,就会调用这些接口获取 libspace 基地址,__user_libspace 是早期接口,直接返回 libspace 基地址,__user_perproc_libspace 和 __user_perthread_libspace 是后来拆分的,用于返回进程共享区和线程私有区,这些接口我们也可以进行重写

这里可先理解为,进程共享表示同一程序中的各个任务共用;线程私有则表示不同任务各自拥有一份。

库会使用哪个接口获取状态区?这不是根据单线程或多线程模式二选一。 访问哪一个状态,根据状态类型,就采用相应的访问路径 。在裸机未重写接口情况下,即使访问线程私有状态, __user_perthread_libspace 也返回和 __user_perproc_libspace 一样的同一块空间基址。函数名带 perthread,不表示必须有多个线程才能调用;它只是负责返回当前线程的库状态区。裸机固件反汇编可以看到,三个标号都返回同一片 libspace 的基地址,我这个裸机空工程固件返回 0x2000 0000。

2. 通过 errno 观察状态的访问过程

不过,访问某个状态时,不一定直接调用上面的三个接口。有些状态还有专门的地址访问接口,例如 errno。

errno 用于保存错误码,它通过 __aeabi_errno_addr() 获取存储地址,这儿我写了个 errno 的操作示例代码,通过反汇编和运行结果来看看访问流程。

c 复制代码
#include <errno.h>

volatile int* errno_addr;
volatile int errno_value;

int main(void) {
    errno_addr = &errno;
    errno = 123;
    errno_value = errno;
    while(1);
    return 0;
}

反汇编固件,首先看到 libspace 被建立在 0x2000 0000上,大小 96 个字节。

再看 main 函数中,对 errno 的访问,被反汇编成了__aeabi_errno_addr,对应了程序中的三条代码:

c 复制代码
errno_addr = &errno;
errno = 123;
errno_value = errno;

接着分析 __aeabi_errno_addr 的内部调用,看到返回了 libspace 的基址 0x2000 0000。

运行一下代码,看看结果和内存数据

可以看到,errno 的地址是 0x20000000,它的值也被改成了 123(十六进制为 0x7B)。通过这个实验,可以观察到程序访问 libspace 中库状态的过程。

不过,这里为什么出现的是 __aeabi_errno_addr?之前提到的状态区访问接口是 __user_perthread_libspace 和 __user_perproc_libspace,它们之间是什么关系?为什么得到的地址恰好等于 libspace 的基址?

__aeabi_errno_addr 专门用于获取 errno 的地址。由于后者属于线程私有状态,可以用下面的函数模型来理解:先获取当前线程的库状态区基址,再加上该变量在状态区内的偏移。

c 复制代码
/* 仅用于解释访问关系,不代表 Arm C 库的实际源码实现。 */
void *__aeabi_errno_addr(void) {
    unsigned char *base = (unsigned char *)__user_perthread_libspace();
    return (void*)(base + ERRNO_OFFSET);
}

在这个模型中,线程状态区的基址由 __user_perthread_libspace 提供。在我的工程里,观察到的变量地址与 libspace 基址相同,对应的偏移为 0,因此最终得到的地址也是 0x2000 0000。

这里我重写 __user_perthread_libspace,让它返回另一块内存地址,不走默认调用,反汇编可以看到 __aeabi_errno_addr 底层就是调用 __user_perthread_libspace。

3. 给每个任务分配独立的 libspace

默认情况下,共有状态和线程私有状态都放在 libspace,通过 __user_perthread_libspace 或 __user_perproc_libspace 访问

如果在多任务环境下,两个任务同时使用 errno 或其他私有状态,大概率就会发生以下的结果

任务 A:操作失败,保存错误码 A

任务 B:操作失败,覆盖为错误码 B

任务 A:读取错误码,却读到了 B 的结果

可以写个代码复现一下这种情况。我在 ARM C 库下的 FreeRTOS 工程里,创建并启动了两个同优先级的任务互相抢占,在两个任务里,都为 errno 赋值再读出,且不同任务赋的值不同,如果数据被破坏或修改就会在 while (1); 处卡住

c 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include <stdint.h>
#include <errno.h>

void vTask1(void *pvParameters) {
    (void)pvParameters;
    for(;;) {
        errno = 0x01;
        vTaskDelay(pdMS_TO_TICKS(1000));
        if (errno != 0x01) {
            while (1);
        }
    }
}

void vTask2(void *pvParameters) {
    (void)pvParameters;
    for(;;) {
        errno = 0x02;
        vTaskDelay(pdMS_TO_TICKS(1000));
        if (errno != 0x02) {
            while (1);
        }
    }
}

int main(void) {
    xTaskCreate(vTask1, "Task1", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
    xTaskCreate(vTask2, "Task2", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
    vTaskStartScheduler();
    while(1);
    return 0;
}

运行可以看到,调试指针停在了任务 1 的 while (1);,说明读出的数据已经不是前面写的 0x01 了,看 errno(0x2000 0008) 的内存数据也能看到是 0x0000 0002,任务 2 把任务 1 写的 errno 覆盖了。

这里仅是使用 errno 举例,其他依靠私有状态的标准库也是一样,多线程情况下未进行私有状态隔离同时操作库,库内私有状态会错误甚至出现更严重的问题。那么怎么进行私有状态的隔离?

我们可以在每个任务的任务控制块里留一块空间,重写 __user_perthread_libspace,在任务切换时指向任务自己的空间,那样再操作私有状态就不会互相干扰了。

ARM 官方文档《ARM C and C++ Libraries and Floating-Point Support User Guide》规定,__user_perproc_libspace 和 __user_perthread_libspace 都必须返回 96 字节大小 4 字节对齐的内存。

那么我们可以这样实现对私有状态的保护,以下为示意:

c 复制代码
typedef struct {
    /* 其他任务信息 */
    unsigned int libspace[24];
} TaskControlBlock;

在创建任务时初始化它的状态区,访问接口根据当前任务返回地址:

c 复制代码
void *__user_perthread_libspace(void) {
    return (void *)current_tcb.libspace;
}

于是之前的情况就变成了:

任务 A:操作失败,读取 A 的 libspace,保存错误码 A

任务 B:操作失败,读取 B 的 libspace,保存错误码 B

任务 A: 读取错误码,读到 A 的结果

这块空间也不一定放在 TCB 中。ARM 官方 RTX 的一种实现使用独立数组保存多份 libspace,再通过线程 ID 建立对应关系,这里仅做示例。

在刚才的工程里对 __user_perthread_libspace 接口进行重写,实现私有状态隔离。如果调度器还没启动,就使用默认的 libspace,启动后再通过当前任务来返回不同的空间基址,这里仅用于演示,实际可以使用 FreeRTOS 的 TLS 指针槽。

c 复制代码
#include <string.h>

extern void *__user_libspace(void); 
uint32_t task1_libspace[24] = {0}; 
uint32_t task2_libspace[24] = {0}; 

void *__user_perthread_libspace(void) { 
    if (xTaskGetSchedulerState() == taskSCHEDULER_NOT_STARTED) { 
        return __user_libspace(); 
    } 
    
    TaskStatus_t task_status; 
    TaskHandle_t task_handle = xTaskGetCurrentTaskHandle(); 
    vTaskGetInfo(task_handle, &task_status, pdFALSE, eInvalid); 
    
    if(strcmp(task_status.pcTaskName, "Task1") == 0) {
        return (void *)task1_libspace;
    }
    if(strcmp(task_status.pcTaskName, "Task2") == 0) {
        return (void *)task2_libspace;
    }
    else {
        return __user_libspace(); 
    }
}

再次运行,每个任务的私有状态就被放在各自的空间了,没有互相覆盖。

4. 通过 mutex* 接口保护共享资源

对于私有状态,重写 __user_perthread_libspace(),根据当前任务返回不同的存储区域,可以让每个任务维护自己的状态副本,从而避免任务之间相互覆盖。

共享资源则不同。例如,多个任务调用 malloc()、free() 操作同一个堆时,必须共同维护一致的堆管理状态。如果任务 A 更新这些状态的过程中被任务 B 抢占,而任务 B 又在缺少保护的情况下操作同一个堆,就可能破坏堆管理数据。

这里可以做个实验验证一下这种情况,模拟多线程里无保护操作共享资源,创建两个同等优先级任务互相抢占,都申请一块 32 字节的堆内存,然后往这块内存里写值,任务 1 写 1,任务 2 写 2,写完再释放掉,循环往复。

c 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include <stdint.h>
#include <stdlib.h>

void vTask1(void *pvParameters) {
    (void)pvParameters;
    
    while (1) {
        volatile uint8_t *p = malloc(32);
        
        if (p != NULL) {
            for (uint32_t i = 0; i < 32; i++) {
                p[i] = 1;
            }
            free((void *)p);
        }
    }
}

void vTask2(void *pvParameters) {
    (void)pvParameters;

    while (1) {
        volatile uint8_t *p = malloc(32);
        
        if (p != NULL) {
            for (uint32_t i = 0; i < 32; i++) {
                p[i] = 2;
            }
            free((void *)p);
        }
    }
}

int main(void) {
    xTaskCreate(vTask1, "Task1", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
    xTaskCreate(vTask2, "Task2", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
    vTaskStartScheduler();
    while(1);
    return 0;
}

运行发现触发了 HardFault

这个时候我们要结合异常栈、反汇编、内核和故障寄存器来分析。 进入 HardFault 处理程序后,处理器使用的是 MSP,但这不代表故障发生在使用 MSP 的代码中。这里 LR 的值是 0xFFFF FFFD,表明异常发生前处于使用 PSP 的线程模式。在这个 FreeRTOS 工程中,也就是任务执行期间发生了故障,因此需要从 PSP 指向的异常栈中找现场。

本次异常进入时,硬件会将 R0、R1、R2、R3、R12、LR、PC、xPSR 压栈,PSP 的值是 0x2000 02A8,在内存窗口输入这个地址,查看前 32 字节,就能拿到这组寄存器的值。

我们主要关注压栈的 PC,用它定位出错的位置。我这里读到的是 PC=0x0800 02EE。

反汇编查看 0x0800 02EE,可以看到它位于 free() 内部。先关注这一条指令:

bash 复制代码
LDRCC r4, [r4, #4]

在条件满足并执行时,它的内存读取操作可以理解为:

ini 复制代码
r4 = *(uint32_t *)(r4 + 4);

也就是说,程序在通过 r4 访问堆管理数据时出现了问题。至于访问了什么地址、为什么出错,还需要结合故障寄存器继续分析。

这里主要看两个寄存器:CFSR 和 BFAR,可以分别通过 SCB->CFSR 和 SCB->BFAR 访问。

首先看 CFSR,地址是 0xE000 ED28。我这里的值是 0x0000 8200,其中 bit9(PRECISERR)和 bit15(BFARVALID)置位:

  • PRECISERR 表示发生了精确的数据总线错误,异常栈中保存的 PC 指向致错指令。
  • BFARVALID 表示 BFAR 寄存器中保存了有效的故障访问地址。

其他寄存器和位定义可以参考 Arm 官方故障分析文档,这里不展开。

再看 BFAR,地址是 0xE000 ED38。图中可以看到 BFAR=0x0101 0105,这就是本次出错的访问地址。

结合前面的 LDRCC r4, [r4, #4],可以推得访问前的 r4 为:

ini 复制代码
0x01010105 - 4 = 0x01010101

这个值刚好对应任务 1 连续写入的字节 0x01。本该用于访问堆管理数据的指针,出现了任务写入的数据模式,说明堆管理数据已经遭到破坏。结合两个任务无保护地操作同一个堆,以及后面加锁后的对照结果,可以把问题定位到堆操作的并发冲突。

在这个例子里,即使按照上一节说的重写 __user_perthread_libspace(),为各个任务分开保存私有状态,也不能解决共享堆的并发问题。那么,是不是可以重写 __user_perproc_libspace() 来解决呢?

两者解决的问题不同,私有状态通过独立存储避免相互干扰,共享资源则需要在保持共享的前提下协调访问。

__user_perproc_libspace() 可以重写,但仅仅重写这个接口,并不能实现对共享资源的并发保护。为每个任务返回不同的区域,会把原本应当共享的进程级库状态拆成多个副本,却不会自动为每个任务创建独立的堆,也就不能保证对同一个堆的并发操作是安全的。此外,不能假定堆管理数据全部存放在 libspace 中。

因此,在同一进程的多任务环境下,__user_perproc_libspace() 通常保留默认实现。共享资源的并发保护,则应通过同步机制完成。

Arm C 提供了以下 _mutex_* 接口,库在需要保护共享资源的位置调用这些接口,我们可以在其中适配 RTOS 的互斥锁。

c 复制代码
int  _mutex_initialize(mutex *m);
void _mutex_acquire(mutex *m);
void _mutex_release(mutex *m);
void _mutex_free(mutex *m);

在 ARM 官方文档《ARM C and C++ Libraries and Floating-Point Support User Guide》的 "Management of locks in multithreaded applications" 一节,对这些接口有详细说明。

这里可以将 mutex 定义为锁句柄的类型。库调用 _mutex_initialize() 时,需要把创建好的锁句柄存入参数 m 指向的位置;操作共享资源时,通过 _mutex_acquire() 和 _mutex_release() 加锁和解锁;不再需要锁时,可以通过 _mutex_free() 销毁锁。

实测发现,标准库内部并非共用同一把锁 ,而是会为不同模块分别初始化锁,并独立保存各自的锁句柄。后续调用 _mutex_acquire() 和 _mutex_release() 时,参数 m 指向相应锁句柄的保存位置,因此不同模块可以使用各自的锁进行同步。

如果没有重写这些接口保持默认,也没有提供有效的锁实现,就无法依靠这些接口保护共享资源。

这里我有两个踩过的坑需要说一下:

第一个,Arm C 库对可选锁钩子使用弱引用。我在测试中,即使实现了上述接口,加解锁函数仍未被调用。加入链接选项 --keep=_mutex_* 后,调用恢复;为函数添加 __attribute__((used)),在本工程中也能达到同样的效果,建议通过 --keep 明确要求链接器保留这些函数,也可以同时保留 used 属性。

这涉及钩子函数的保留问题。仅凭上述现象,还不能完全确定具体原因;需要进一步检查目标文件及链接器的未使用段删除记录,区分编译阶段的消除与链接阶段的删除。

第二个,文档里说 _mutex_initialize 非多线程环境默认返回 0,用于多线程环境时,初始化成功必须返回非 0,让库知道当前使用的是多线程环境。但在我的工程中,观察到的调用路径并没有使用这个返回值。

我在代码里使用了 atexit() 和 malloc(),然后通过反汇编分析 _mutex_initialize() 返回值的去向。

分析 atexit 的初始化路径时,_atexit_init 最后通过:

css 复制代码
B.W _mutex_initialize

尾调用 _mutex_initialize(),并将返回值存到 R0 寄存器。我观察到返回上层调用者后,POP {r0-r4,pc} 覆盖了 R0,这条路径没有使用初始化返回值。

再分析 malloc 的初始化路径。在我跟踪的这条路径中,_mutex_initialize() 的返回值保留在 R0 中,从 __Heap_Initialize 返回后,后续的 LDR r0,[r7,#0] 又覆盖了 R0,同样没有使用这个返回值。

后面又反复测试了多次,在我的工程中,改变 _mutex_initialize() 的返回值,并没有阻止 _mutex_acquire() 和 _mutex_release() 被调用。

这只是当前工程中的观察,不能据此认为返回值没有作用。实际实现仍应遵守接口约定,在初始化成功时返回非 0。

更需要注意的是,不能指望初始化返回 0,库就一定不会继续调用加解锁接口。锁创建失败或获取失败时,必须进入有效的错误处理,不能直接返回,让库继续无锁操作共享资源。

下面给前面的实验适配 FreeRTOS 互斥锁。我使用的是 heap_4.c,所以这里可以使用动态创建锁的写法,如果是 heap_3.c,xSemaphoreCreateMutex 内部会调用 malloc 导致循环依赖,这种情况下要考虑用独立的静态存储创建锁。因此,这段代码不能直接套用到 heap_3.c

c 复制代码
#include "semphr.h"

typedef SemaphoreHandle_t mutex;

static void library_mutex_error(void) {
    taskDISABLE_INTERRUPTS();

    for (;;) {
        /* 可在这里设置断点,或接入工程的故障处理。 */
    }
}

int  _mutex_initialize(mutex *m) {
    if (m == NULL) {
        library_mutex_error();
    }

    *m = xSemaphoreCreateMutex();

    if (*m == NULL) {
        library_mutex_error();
    }

    return 1;
};

__attribute__((used))
void _mutex_acquire(mutex *m) {
    if ((m == NULL) || (*m == NULL)) {
        library_mutex_error();
    }

    if (xSemaphoreTake(*m, portMAX_DELAY) != pdTRUE) {
        library_mutex_error();
    }
};

__attribute__((used))
void _mutex_release(mutex *m) {
    if ((m == NULL) || (*m == NULL)) {
        library_mutex_error();
    }

    if (xSemaphoreGive(*m) != pdTRUE) {
        library_mutex_error();
    }
}

__attribute__((used))
void _mutex_free(mutex *m) {
    if ((m == NULL) || (*m == NULL)) {
        library_mutex_error();
    }

    vSemaphoreDelete(*m);
    *m = NULL;
}

适配互斥保护后,再次运行就未出现原先的 HardFault。

实际应用中,也可以统一使用 FreeRTOS 提供的 pvPortMalloc()、vPortFree(),或者在标准库调用外部统一加锁。重写 _mutex_* 接口的好处,是将库的加解锁操作从代码中剥离出来,降低耦合,在继续使用同一套 Arm C 库的前提下,如果以后从 FreeRTOS 换成 RT-Thread 或 μC/OS,这部分互斥适配可以集中修改,业务代码中的标准库调用通常不需要跟着逐处调整。

三、Newlib 如何管理库状态

前面讨论了 Arm C 中库状态的存储和访问,以及线程私有状态和共享资源的保护。如果使用的是搭配 Newlib 的 GCC 工具链,同样需要处理这些问题,整体思路和 Arm C 类似,只是具体的数据结构和接口有所不同。

1. 保存库状态的 struct _reent

Arm C 将部分库状态保存在 libspace 中,而 Newlib 使用 struct _reent 将部分库状态组织起来,通过这个结构体的实例保存。

打开 Newlib 源码中的 sys/reent.h,可以看到结构体定义。下面节选其中的一些成员,省略其他字段和条件编译分支:

c 复制代码
struct _reent
{
    int _errno;

    __FILE *_stdin, *_stdout, *_stderr;

    /* 其他成员 */

    struct __locale_t *_locale;
    void (*__cleanup)(struct _reent *);

    /* 浮点数转换所需的内部状态 */
    struct _Bigint *_result;
    int _result_k;
    struct _Bigint *_p5s;
    struct _Bigint **_freelist;

    int _cvtlen;
    char *_cvtbuf;

    union
    {
        struct
        {
            char *_strtok_last;
            char _asctime_buf[_REENT_ASCTIME_SIZE];
            struct __tm _localtime_buf;

            int _gamma_signgam;
            __extension__ unsigned long long _rand_next;
            struct _rand48 _r48;

            _mbstate_t _mblen_state;
            _mbstate_t _mbtowc_state;
            _mbstate_t _wctomb_state;

            /* 其他成员 */
        } _reent;
    } _new;

    /* 其他成员 */
};

不同版本和配置下,结构体的布局会有所不同,但不影响我们理解它的用途。按功能大致分类,可以看到它保存了这些状态:

功能 成员举例
错误码 _errno、_h_errno、_getdate_err
标准 IO 入口 _stdin、_stdout、_stderr
浮点转换缓存 _result、_result_k、_p5s、_freelist、_cvtlen、_cvtbuf
字符串与时间处理 _strtok_last、_asctime_buf、_localtime_buf、_l64a_buf
随机数状态 _rand_next、_r48
数学函数状态 _gamma_signgam
多字节字符转换状态 _mblen_state、_mbtowc_state、_wctomb_state 等
locale 与信号相关状态 _locale、_getlocalename_l_buf、_signal_buf、_sig_func
清理回调 __cleanup

并不是所有库状态都放在 _reent 中,有些则通过指针访问其他存储区域。 前面提到,libspace 里只存储部分状态或者指针,_reent 也是一样的。

在这里的 _reent 中,标准流的 FILE 对象由全局数组保存,_reent 中的 _stdin、_stdout、_stderr 保存的是指向这些对象的指针。

以 Newlib 的 nano 内存分配器为例,打开 nano-mallocr.c,可以看到空闲链表头等管理信息使用的是全局变量,链表中的内存块节点则位于堆上:

2. 通过 _impure_ptr 切换任务的库状态

Arm C 通过 __user_libspace, __user_perproc_libspace 和 __user_perthread_libspace 等接口取得相应的 libspace 地址,Newlib 的这种方案则使用 _impure_ptr,指向当前使用的 _reent 实例。

打开 impure.c,可以看到默认的 _reent 实例,以及指向它的 _impure_ptr。在没有进行多任务适配时,库可以通过这个指针使用默认状态区。

因此,为不同任务准备各自的 _reent 后,可以在任务切换时更新 _impure_ptr,让库访问当前任务对应的状态:

任务 A 运行:_impure_ptr → A 的 _reent

任务 B 运行:_impure_ptr → B 的 _reent

这和 Arm C 中根据当前任务返回不同的 libspace 地址,解决的是同一类问题。区别在于,这里通过切换指针来选择状态区。

FreeRTOS 已经为这套机制做了适配。前面说过,任务的库状态区可以放在 TCB 中,FreeRTOS 的 Newlib 方案就是这么处理的。

以 FreeRTOS V10.4.6 为例,TCB 中有下面的定义,其他成员和说明性注释已省略:

arduino 复制代码
typedef struct tskTaskControlBlock
{
    /* 其他成员 */

#if (configUSE_NEWLIB_REENTRANT == 1)
    struct _reent xNewLib_reent;
#endif

    /* 其他成员 */
} tskTCB;

在配置文件中启用:

arduino 复制代码
#define configUSE_NEWLIB_REENTRANT 1

创建任务时,FreeRTOS 会初始化任务对应的 _reent:

scss 复制代码
_REENT_INIT_PTR(&(pxNewTCB->xNewLib_reent));

启动调度器和切换任务时,再将 _impure_ptr 指向当前任务的 xNewLib_reent。在这个版本的 vTaskStartScheduler()、vTaskSwitchContext() 中,可以看到类似下面的代码:

c 复制代码
#if (configUSE_NEWLIB_REENTRANT == 1)
{
    _impure_ptr = &( pxCurrentTCB->xNewLib_reent );
}
#endif

所以,configUSE_NEWLIB_REENTRANT 解决了每任务库状态的管理问题,但共享堆等资源仍然需要另外保护。

3. 通过专用锁和 _retarget_lock* 接口保护共享资源

Newlib 对共享资源的处理思路与 Arm C 类似,都是库在需要保护的位置调用锁接口,再由平台适配提供实际的同步机制。

先看堆。Newlib 提供了下面两个接口:

arduino 复制代码
void __malloc_lock(struct _reent *r);
void __malloc_unlock(struct _reent *r);

malloc()、free() 等堆操作会在需要保护堆管理数据的位置调用这些接口。以前面提到的 nano 分配器为例,源码中定义了:

arduino 复制代码
#define MALLOC_LOCK   __malloc_lock(reent_ptr)
#define MALLOC_UNLOCK __malloc_unlock(reent_ptr)

在修改空闲链表等共享数据前后,通过这些宏完成加锁和解锁。

这儿的参数 struct _reent *r 用于记录的重入状态,并不是锁句柄。

另外,Newlib 的堆操作可能在同一个任务中多次获取这把锁。例如,外层操作已经持有堆锁,内部调用又需要获取堆锁。

第一次获取堆锁

第二次获取同一把堆锁

释放一次

再释放一次

这里的接口约定与 Arm C 不同,ARM 官方在 _mutex_acquire() 的描述里明确说到,已经持有该锁的线程,不会再次调用这个接口获取同一把锁。

Newlib 的堆锁则需要支持递归获取,即同一个任务可以重复获取自己持有的锁,但必须释放相同的次数,其他任务才能拿到锁。如果使用普通非递归互斥锁,并一直等待第二次获取成功,就可能出现 "自己等自己释放锁" 的死锁。

因此,用 FreeRTOS 互斥锁适配 Newlib 堆锁时,应使用递归互斥锁及配套的获取、释放接口。

这里的 __malloc_lock() 是堆锁的专用接口。除了堆,Newlib 还有环境变量、时区状态、文件流等需要同步访问的资源,也提供了相应的锁操作,比如 __env_lock()、__tz_lock()。

这些专用接口的默认实现使用 sys/lock.h 提供的通用锁操作,如果工具链中的 Newlib 在编译时启用了锁重定向功能,这些接口底层会进一步调用 __retarget_lock_*() 系列接口,主要包括:

scss 复制代码
/* 普通锁:初始化、获取、尝试获取、释放、销毁 */
__retarget_lock_init(_LOCK_T *lock)
__retarget_lock_acquire(_LOCK_T lock)
__retarget_lock_try_acquire(_LOCK_T lock)
__retarget_lock_release(_LOCK_T lock)
__retarget_lock_close(_LOCK_T lock)

/* 递归锁:初始化、获取、释放、销毁 */
__retarget_lock_init_recursive(_LOCK_T *lock)
__retarget_lock_acquire_recursive(_LOCK_T lock)
__retarget_lock_try_acquire_recursive(_LOCK_T lock)
__retarget_lock_release_recursive(_LOCK_T lock)
__retarget_lock_close_recursive(_LOCK_T lock)

我们可以在工程中实现这些接口,在内部调用 FreeRTOS 对应的锁操作。例如,普通锁的获取、释放接口分别调用 xSemaphoreTake()、xSemaphoreGive(),递归锁则调用 xSemaphoreTakeRecursive()、xSemaphoreGiveRecursive()。

之前 ARM C 的 mutex 由我们自己定义,这儿的 _LOCK_T 定义如下:

c 复制代码
struct __lock;
typedef struct __lock * _LOCK_T;

__lock 还是需要我们自己定义,但适配思路和 ARM C 的 mutex 也都差不多。不同资源可以传入不同的锁对象,共用这套接口。

这里对比 ARM C 的 _mutex_* 接口适配做个普通锁适配的 FreeRTOS 示例,完整接入 Newlib,除了补齐递归锁、尝试获取锁等接口,还需要按所用版本提供并初始化库使用的静态锁对象,例如 __lock___malloc_recursive_mutex。下面只演示普通锁的适配过程,同样,这里使用的 heap_4.c。

c 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
#include <sys/lock.h>


struct __lock {
    SemaphoreHandle_t handle;
};

static void library_mutex_error(void) {
    taskDISABLE_INTERRUPTS();

    for (;;) {
        /* 设置断点,或接入工程的故障处理。 */
    }
}

void __retarget_lock_init(_LOCK_T *lock)
{
    if (lock == NULL) {
        library_mutex_error();
    }

    /* 第一次分配:保存 Newlib 锁对象 */
    struct __lock *obj = pvPortMalloc(sizeof(*obj));

    if (obj == NULL) {
        library_mutex_error();
    }

    /* 第二次分配:创建实际的 FreeRTOS 互斥锁 */
    obj->handle = xSemaphoreCreateMutex();

    if (obj->handle == NULL) {
        vPortFree(obj);
        library_mutex_error();
    }

    /* 将锁对象地址交给 Newlib 保存 */
    *lock = obj;
}

void __retarget_lock_acquire(_LOCK_T lock)
{
    if ((lock == NULL) || (lock->handle == NULL)) {
        library_mutex_error();
    }

    if (xSemaphoreTake(lock->handle, portMAX_DELAY) != pdTRUE) {
        library_mutex_error();
    }
}

void __retarget_lock_release(_LOCK_T lock)
{
    if ((lock == NULL) || (lock->handle == NULL)) {
        library_mutex_error();
    }

    if (xSemaphoreGive(lock->handle) != pdTRUE) {
        library_mutex_error();
    }
}

void __retarget_lock_close(_LOCK_T lock)
{
    if ((lock == NULL) || (lock->handle == NULL)) {
        library_mutex_error();
    }

    vSemaphoreDelete(lock->handle);
    lock->handle = NULL;

    vPortFree(lock);
}

库调用普通版本还是递归版本,由库代码按所使用的锁类型决定。递归锁从第一次获取开始就使用递归接口,并不是第二次获取时才切换。

这样就有两种适配位置可以选择。

可以保留 Newlib 自带的专用锁默认实现,在下面的通用锁接口中接入 FreeRTOS。这样,堆和其他通过这套接口加锁的库资源,都可以使用这层适配。

也可以重写某个专用锁接口,例如在 __malloc_lock()、__malloc_unlock() 中直接调用 FreeRTOS。此时堆锁操作会走重写的实现,不再经过 Newlib 默认的通用锁路径,但其他资源的锁仍需另外检查和适配。

到这里,ARM C 和 Newlib 对库状态的管理方式就都看过了。接口虽然不同,但思路差不多。把前面的内容放在一起对比一下:

前面讨论的问题 ARM C Newlib
私有状态放在哪里 libspace struct _reent
如何找到当前任务的状态 __user_perthread_libspace() 返回对应地址 _impure_ptr 指向对应实例
如何保护共享资源 适配 _mutex_* 接口 适配专用锁接口或 __retarget_lock_* 接口

前面考虑的都是任务之间互相抢占的情况。不过,任务执行到一半,还可能被中断打断。如果中断里也调用了这些库函数,前面的保护是否还够用?接下来就看看这种情况。

四、中断安全

前面分别看了 ARM C 和 Newlib 在多任务下的处理办法,主要就是两件事:私有状态各用各的,共享资源通过锁来保护。

在前面的 ARM C 库实验里,加上这些保护后,两个任务已经可以正常运行了。那么,如果任务执行到一半被中断打断,中断里也调用了库函数,还会不会出问题?

我们还是从私有状态和共享资源这两个方面来看。

1. 中断使用的是谁的私有状态

前面重写 __user_perthread_libspace(),是根据当前任务返回对应的 libspace;Newlib 则是在任务切换时,让 _impure_ptr 指向当前任务的 _reent。

但进入中断时,并没有切换到另一个任务,也没有自动给中断准备一份库状态区。沿用前面的实现,中断访问的仍然可能是被打断任务的那一份状态。

还是拿 errno 举例:

任务 B:调用库函数失败,任务 B 的 errno 被设置为错误码 B。

中断:调用库函数失败,把同一位置的错误码改成了 C。

任务 B:中断返回后读取 errno,读到的却是 C。

虽然任务之间已经隔离了,但中断又把任务的状态改掉了。

这儿可以继续用前面的例子验证。任务 1 和任务 2 分别向 errno 写入 0x01 和 0x02,保留之前的私有状态隔离,再加一个每 500ms 触发的定时器中断,在中断里向 errno 写入 0x03。

不过,前面任务里的 vTaskDelay() 这里要先注释掉。否则两个任务都在延时时,处理器通常运行的是空闲任务,中断里取得的当前任务名就可能是 "IDLE",按我们的实现会返回默认 libspace。这样改到的就不是任务 1 和任务 2 的状态,实验现象也就不容易观察到了。

c 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include <stdint.h>
#include <errno.h>
#include <string.h>
#include "stm32f4xx.h"

extern void *__user_libspace(void); 
uint32_t task1_libspace[24] = {0}; 
uint32_t task2_libspace[24] = {0}; 

static void timerInit500ms(void) {
    TIM_TimeBaseInitTypeDef  TIM_TimeBaseStructure;
    NVIC_InitTypeDef         NVIC_InitStructure;

    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);

    TIM_TimeBaseStructure.TIM_Prescaler     = 8400 - 1;
    TIM_TimeBaseStructure.TIM_Period        = 5000 - 1;
    TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
    TIM_TimeBaseStructure.TIM_CounterMode   = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);

    TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE);

    NVIC_InitStructure.NVIC_IRQChannel                   = TIM3_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x01;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority        = 0x01;
    NVIC_InitStructure.NVIC_IRQChannelCmd                = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    TIM_Cmd(TIM3, ENABLE);
}

void TIM3_IRQHandler(void) {
    if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) {
        TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
        errno = 0x03;
    }
}

void *__user_perthread_libspace(void) { 
    if (xTaskGetSchedulerState() == taskSCHEDULER_NOT_STARTED) { 
        return __user_libspace(); 
    } 
    
    TaskStatus_t task_status; 
    TaskHandle_t task_handle = xTaskGetCurrentTaskHandle(); 
    vTaskGetInfo(task_handle, &task_status, pdFALSE, eInvalid); 
    
    if(strcmp(task_status.pcTaskName, "Task1") == 0) {
        return (void *)task1_libspace;
    }
    if(strcmp(task_status.pcTaskName, "Task2") == 0) {
        return (void *)task2_libspace;
    }
    else {
        return __user_libspace(); 
    }
}

void vTask1(void *pvParameters) {
    (void)pvParameters;
    for(;;) {
        errno = 0x01;
        // vTaskDelay(pdMS_TO_TICKS(1000));
        if (errno != 0x01) {
            while (1);
        }
    }
}

void vTask2(void *pvParameters) {
    (void)pvParameters;
    for(;;) {
        errno = 0x02;
        // vTaskDelay(pdMS_TO_TICKS(1000));
        if (errno != 0x02) {
            while (1);
        }
    }
}

int main(void) {
    timerInit500ms();
    xTaskCreate(vTask1, "Task1", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
    xTaskCreate(vTask2, "Task2", configMINIMAL_STACK_SIZE, NULL, 1, NULL);
    vTaskStartScheduler();
    while(1);
    return 0;
}

运行可以看到,任务 2 写入的 errno 被中断改成了 0x03,中断返回后,任务检查到数据不一致,就停在了 while (1);。

那么,能不能像给任务分配 libspace 一样,也给中断分配一份?

可以,思路还是前面那样,只不过现在除了区分任务,还要区分中断。在 Cortex-M 中,可以通过 IPSR 判断当前是否处于中断,以及正在执行哪个中断。这样就可以在取得任务信息之前,先把中断的访问分出去,例如:

c 复制代码
uint32_t tim3_libspace[24] = {0};

void *__user_perthread_libspace(void) {
    if ((__get_IPSR() & 0x01FFU) == (uint32_t)TIM3_IRQn + 16U) {
        return (void *)tim3_libspace;
    }

    /* 后面沿用调度器启动判断和任务状态隔离代码 */
}

对于前面通过 _impure_ptr 访问状态的 Newlib,也可以在进入中断后先保存原来的指针,再切换到中断自己的 _reent,退出前恢复:

c 复制代码
void IRQHandler(void) {
    /* irq_reent 需要提前初始化 */
    struct _reent *saved_reent = _impure_ptr;
    _impure_ptr = &irq_reent;

    /* 使用中断自己的私有状态 */

    _impure_ptr = saved_reent;
}

这里展示的是切换思路。如果有多个中断会使用这些状态,还要考虑中断嵌套,不能让它们又共用一份,把任务之间出现过的问题在中断里重演一次。

这样处理后,私有状态可以分开保存了。但 malloc() 操作的堆、printf() 使用的文件流等资源还是共享的,它们又该怎么保护?

2. 中断里的共享资源怎么保护

前面给共享资源加锁时,任务拿不到锁,可以先阻塞,让其他任务继续运行。等持锁任务操作完成、释放锁,等待的任务再接着执行。

但中断里就不能这样等了。

假设任务 A 正在执行 malloc(),已经拿到堆锁,还没来得及完成操作,就被一个同样调用 malloc() 的中断打断:

任务 A:获取堆锁,开始修改堆管理数据。

中断:调用 malloc,拿不到堆锁,等待任务 A 释放。

任务 A:要等中断返回,才能继续执行并释放锁。

中断等任务释放锁,任务又等中断返回,两边就卡住了。即使把等待改成循环查询,处理器还是停留在中断里,任务 A 依然没有机会继续运行。

所以 FreeRTOS 不允许在中断里使用互斥锁,semphr.h 中也有相关说明。我把定时器中断里的 malloc() 放到前面已经适配互斥锁的工程中,运行后触发了断言。

前面 Newlib 的堆锁提到了递归互斥锁,同一个任务可以重复获取,那换成递归锁能不能解决?

这里的问题在于,中断可能恰好打断了一次尚未完成的堆操作。即使把中断当成当前任务,让它再次拿到锁,里面的堆管理数据也可能只更新了一半。这时继续操作,仍然可能把堆弄坏。

在中断里直接跳过加锁也是一样,虽然不用等锁了,但原本需要保护的数据又暴露出来了。

那么,还有没有其他办法?

可以换个思路:既然问题出在中断插入了共享数据的修改过程,那就在修改期间屏蔽相关中断,等数据改完后再恢复。

仍然拿共享堆举例:

任务 A:保存原来的中断屏蔽状态,屏蔽可能访问同一堆的中断。

任务 A:完成堆操作,恢复原来的中断屏蔽状态。

中断:随后执行,访问已经完成更新的堆管理数据。

这样,中断就不会看到任务改到一半的数据了。不过,要采用这种办法,访问同一个堆的地方都得一起保护,不能只包住某一处 malloc(),漏掉 free(),或者其他库函数内部的内存申请。

而且,关了哪些中断,也得看清楚。 比如 FreeRTOS 在 Cortex-M4 上进入临界区,通常只屏蔽一部分优先级的中断。如果更高优先级的中断仍然可以运行,又恰好访问了同一个堆,问题还是存在。

那把普通中断都屏蔽掉呢?屏蔽范围是够了,但其他中断也得跟着等。一次内存分配还没结束,串口接收、定时器处理等事情就都可能被推迟,malloc() 花多少时间又和当时的堆状态有关。

中断关了,外设可不会跟着停下来。 串口还在接收数据,缓冲区装不下就可能丢数据;定时器发生了多次事件,也可能只留下一个中断标志,之后再恢复中断,不一定能把前面的次数补回来。

还有一种情况更容易卡住:库函数自己就在等中断。比如 printf() 底层使用串口中断发送,并等待发送完成,如果把对应中断关掉再调用它,程序就会一直等一个无法执行的中断。

所以,关中断确实可以用来保护共享数据,但具体放到某个库函数上,还得看看它内部做了什么、会执行多久,不能简单包一层"关中断、开中断"就结束了。

3. 把复杂处理交给任务

讨论到这里可以发现,要让这些库函数在中断里正常使用,需要处理的事情不少:私有状态要分开,共享资源要保护,还不能等锁,也不能长时间影响其他中断。

实际开发中,通常可以把这部分工作交给任务,中断里先把必要的数据留下来。

比如要在中断里打印日志,可以先记录事件编号和几个参数,再通知日志任务,由任务完成格式化和输出。串口收到数据也是一样,先放进提前准备好的缓冲区,后面的解析和内存申请交给任务处理。

在 FreeRTOS 中,可以用队列、任务通知等方式把事情交出去,中断里使用对应的 FromISR 接口,并注意中断优先级的要求。

当然,这也不是说中断里一个 C 库函数都不能用。没有共享状态冲突、不需要等待、执行时间又比较短的操作,还是可以结合具体实现来判断。

回头看,前面的实验其实一直在追同一个问题:调用库函数时,它到底还动了哪些数据? 任务之间可以通过独立状态区和互斥锁来处理,到了中断里,还要接着看这些办法是否适用。弄清楚这一点,也就更容易理解,为什么有些函数在任务里用着没事,放进中断后却会出问题。

相关推荐
Zwawa6 小时前
反馈:让系统知道自己做得对不对
c语言·单片机·嵌入式
蓝天居士8 小时前
PY32F系列MCU在OTA时App区概率性跑不起来的根因分析(2)
mcu·嵌入式
剑指offer.1 天前
ARM裸机开发-SPI
arm开发·嵌入式硬件·嵌入式·arm
___波子 Pro Max.1 天前
软件开发里的 prebuilt 是什么
嵌入式
7yewh2 天前
SLAM 三维空间刚体运动(2)
数据结构·人工智能·机器人·嵌入式·slam
LCG元2 天前
树莓派 Pico RP2040 PIO 逻辑分析仪实战:SM 指令周期拆解、DREQ 触发 DMA 环形缓冲与采样率参数实测
嵌入式·树莓派·rp2040
7yewh2 天前
SLAM 从视觉里程计到建图(6)
数据结构·人工智能·机器人·自动驾驶·嵌入式
Zwawa2 天前
蜂鸣器为什么会响:有源、无源和低电平触发
单片机·嵌入式
LCG元2 天前
STM32L431 QSPI驱动W25Q256实战:内存映射模式配置、4字节地址模式陷阱与读写吞吐实测
stm32·单片机·嵌入式·flash