Firmware//16 分钟

newlib 与多任务:_reent 隔离、printf 的真实代价与 Keil 的替代方案

newlib 把可变状态塞进 struct _reent,用 _impure_ptr 路由;这解决了隔离容器,却没解决谁来切换指针。附 RAM / Flash / 栈三笔账和 Keil 的对应机制。

newlibFreeRTOSprintf工具链KeilSTM32

一页速查

问题答案
newlib 是什么arm-none-eabi-gcc 默认的 C 标准库实现
核心设计可变状态全塞进 struct _reent,用全局指针 _impure_ptr 指向"当前是谁在用"
没它会怎样printf、memcpy、malloc 全得自己写,#include <stdio.h> 直接链接失败
有它会怎样多任务并发调用有状态函数会互相破坏,隔离代价约 1 KB RAM / 任务
哪些函数有风险printf 族、strtok、rand、gmtime、malloc 族、math、errno
Keil 为什么不用它Arm Compiler 配 Arm C Library,根本不链接 newlib
Keil 的对应机制__user_perthread_libspace;MicroLIB 则完全没有隔离
最易搞混的一点printf 和 vsnprintf 是兄弟(同一份源码的两份副本),不是父子

newlib 在工具链里的位置

arm-none-eabi-gcc
├── 编译器
├── binutils
└── C 库  <- newlib
      ├── libc.a        标准 newlib
      ├── libc_nano.a   精简版(printf 默认不带浮点)
      ├── libm.a        数学
      ├── libnosys.a    系统调用桩(_sbrk / _write ...)
      └── *.specs       nosys.specs / nano.specs / rdimon.specs

nosys.specs 指向标准 newlib,nano.specs 指向精简版。规格文件决定了链接哪套库,也决定了行为差异。

它覆盖的范围:

类别内容
字符串 / 内存memcpy memset strlen strcmp
格式化 I/Oprintf 族、scanf 族、snprintf / vsnprintf
内存分配malloc calloc realloc free
数学logf expf sqrtf(在 libm.a)
错误码errno
时间gmtime localtime

核心机制:状态打包进结构体,用指针路由

_reent 不是函数,是结构体。这是最常被搞混的一点。

struct _reent {
    int                _errno;          /* 当前上下文的 errno */
    const __locale_t  *_locale;         /* 数字格式化 / 字符分类规则 */
    struct __sFILE     _stdin, _stdout, _stderr;
    char              *_strtok_last;    /* strtok 上次停在哪 */
    unsigned long long _rand_next;      /* rand 的种子 */
    void              *_freelist;       /* %f 的大数运算内存池 */
    struct _Bigint    *_p5s;            /* %f 的 5 的幂次表 */
    char              *_env;
    /* ... */
};

真正的路由靠一个全局指针:

struct _reent *_impure_ptr;      /* 谁在用 */

#define _REENT (_impure_ptr)
#define errno  (*__errno())      /* errno 是宏,不是全局变量 */

所有可重入版函数都通过这个指针间接访问状态,普通版只是"拿 _impure_ptr 当参数去调可重入版"的薄包装:

普通版可重入版
printf_printf_r(reent, ...)
vsnprintf_vsnprintf_r(reent, ...)
malloc_malloc_r(reent, size)

这个设计只解决了一半问题

年代C 库做法结果
单线程裸机状态直接放全局变量没问题
多任务出现全局变量变成共享白板前一个写到一半被切走,后一个接着涂,数据错乱
newlib 的答案状态打包进结构体 + 指针指路只提供了容器和指针,切换指针要靠 OS

所以责任是分开的:

角色责任
C 标准库的历史设计用全局变量存状态,问题根源
struct _reent提供隔离容器
_impure_ptr提供路由指针
OS / RTOS任务切换时把指针切到正确那份
应用开发者决定是否启用隔离、是否加锁

C99 / C11 的 7.1.4 节写得很清楚:标准库函数不保证可重入,且可能修改具有静态存储期的对象。线程安全的责任从一开始就划在使用者和运行环境一侧。这不是 newlib 的缺陷,是所有 C 库的共同前提。

没有它会怎样

#include <stdio.h>
printf("hello\r\n");
undefined reference to `puts`      <- newlib 提供
undefined reference to `memcpy`
undefined reference to `_sbrk`
用途没 newlib 的后果
调试打印没有 printf,整数转字符串自己写
内存操作小尺寸编译器会内联成指令,大尺寸仍要运行时实现
算法没有 logf / expf / sqrtf,只能查表或近似
协议编解码没有 strlen / strcmp / memcmp,全靠手写
浮点输出没有 %f,只能自研定点转换

"不用 newlib"的现实含义不是"不链接 C 库",而是换一个实现:

做法说明
nano.specs还是 newlib,只是精简版
换工具链Keil / IAR / GHS 各有自己的 C 库
-nostdlib 加全自研极少见,需完整鉴定
只换 printf 层保留 memcpy / logf,只把格式化换成无状态实现,性价比最高

有它的三笔账

数据量级取自 arm-none-eabi-gcc 10.3。

RAM:每任务约 1 KB

项值
sizeof(struct _reent)约 1064 B
每任务 TCB 开销无 reent 约 80 B,有 reent 约 1144 B
全局 impure_data1064 B
__malloc_av_ 分配表1032 B

7 个任务时,光隔离就吃掉约 7.4 KB。这在只有 20 KB RAM 的 MCU 上是硬约束。

Flash:printf 相关约 12.5 KB

符号大小
_svfprintf_r(vsnprintf 核心)6688 B
_vfprintf_r(printf 核心)6964 B,独立副本
_dtoa_r 加大数运算簇5808 B,仅 %f 需要
printf 相关簇合计12496 B

栈:单次格式化约 470 B

函数栈帧
_svfprintf_r(支持 %f)340 B
_svfiprintf_r(整数专用)244 B

再叠上调用方的行缓冲(比如 char line[128]),一次格式化就吃掉近 470 B 任务栈。

%f 才是最危险的那个

格式符触发路径reent 风险
%d %u %x %s %c %ld整数路径,主要用栈上局部变量低,只读 locale
%f %e %g额外调 _dtoa_r,使用 _freelist / _p5s 做大数运算高,是真正的共享可变状态

浮点路径还有一个实时性代价:大数运算耗时不定,对周期性任务不友好。

两个正交的问题

这是最容易混在一起的地方,必须分开看:

编号问题表现解法
①库内部状态被破坏两个任务共享 _freelist / _locale / _errno,互相涂改reent 隔离、互斥锁、单任务收敛
②输出交错A 打 x=1 被打断,B 打 y=2,串口上出现 x=1y=2整行原子入队、互斥锁

两者不能互相替代:

机制解决 ①解决 ②
configUSE_NEWLIB_REENTRANT = 1是否,各写各的 reent,字符照样插到同一个串口
StreamBuffer 或单任务收敛否,格式化仍在多任务里并发跑是

缺一不可。

实际会怎么坏

时刻 1   A 进入 vsnprintf,开始写共享 impure_data
时刻 2   中断切换 -> B
时刻 3   B 进入 vsnprintf,覆盖了 A 的中间状态
时刻 5   切回 A,从被覆盖的状态继续 -> 输出乱码
         若用了 %f,共享 _freelist 被破坏 -> 后续 malloc 链表损坏 -> HardFault

ISR 里打印更糟:中断上下文里 _impure_ptr 指向谁完全不确定,可能破坏任意任务的 reent。这就是规范里写"禁止在 ISR 中打印"的原因。

多任务用 strtok 解析协议:_strtok_last 被覆盖,产生静默的解析错误,比乱码更难查。

哪些函数有风险

A 类,依赖 reent 状态,多任务下必须串行化:

函数依赖字段典型场景
printf fprintf vfprintf_errno _locale __sf[]重定向到串口
snprintf vsnprintf sprintf_errno _locale _freelist格式化到内存
sscanf scanf_errno _locale解析报文
strtok_strtok_last解析 CSV / AT 命令
strerror perror_strerror_buf错误日志
rand srand_rand_next测试随机数
gmtime localtime asctime返回缓冲区在 reent 内日志时间戳
mblen mbstowcs mbrtowcshift state宽字符
getenv setenv_env读配置
malloc calloc realloc free_freelist 加全局锁动态分配
logf expf sqrtf出错时写 _errno算法计算
assert / errno_errno断言与错误码

B 类,纯计算不碰 reent,可以直接并发:memcpy memmove memset memcmp memchr strlen strcmp strncmp strncpy strchr atoi 以及 isdigit 等字符分类。

strtol 会写 errno,不在安全清单里。这类"看起来无害"的函数要逐个确认。

Keil 为什么"没有这个问题"

一句话:编译器不同,C 库就不同。newlib 只存在于 GCC 工具链里。

项目Keil MDKGCC
编译器Arm Compiler 5 / 6arm-none-eabi-gcc
C 库Arm C Librarynewlib
精简版MicroLIBnewlib-nano
线程隔离__user_perthread_libspacestruct _reent 加 _impure_ptr
系统调用桩retarget(fputc / _sys_*)libnosys.a(_write / _sbrk)
切换开关Target Options 里的 Use MicroLIB-specs=nano.specs
configUSE_NEWLIB_REENTRANT写了也不生效直接生效

所以 Keil 里接触不到 newlib 的问题,不是问题不存在,而是编译器换了。

Arm C 库的隔离靠弱符号接管

void *__user_perthread_libspace(void);   /* 弱符号,可由 RTOS 覆盖 */

它等价于 newlib 的 _impure_ptr,但是否生效取决于有没有人接管:

环境是否接管结果
Arm RTX / CMSIS-RTOS是,内核随任务切换库状态自动隔离,开发者无感知
FreeRTOS 加 Arm Compiler需确认,ARM_CMx port 默认通常不处理退回全局 __user_libspace,和共享状态没区别
MicroLIB否,没有这套机制ARM 文档明确说明不是线程安全的

串口重定向的写法也不同

/* GCC + newlib:覆盖 _write */
int _write(int fd, char *buf, int len) { /* 写串口 */ }

/* Keil + Arm C 库 / MicroLIB:覆盖 fputc */
int fputc(int ch, FILE *f) { /* 写串口 */ }

printf 和 vsnprintf 是兄弟,不是父子

解包 libc.a 看未定义符号就能确认:

对象引用的核心函数
lib_a-printf.o_vfprintf_r,不是 vsnprintf
lib_a-vsnprintf.o_svfprintf_r
        newlib/libc/stdio/vfprintf.c     <- 同一份源码
                  |
     +------------+------------+
     | 副本1:输出到 FILE*      | 副本2:输出到字符串缓冲
     |  _vfprintf_r (6964 B)   |  _svfprintf_r (6688 B)
     |    +- __sprint_r        |    +- __ssprint_r
     |         +- _write       |         +- 直接写 buf
     +------------+------------+
printf/fprintf --+             +-- snprintf/vsnprintf/sprintf

三个推论:

  • 两者的格式化能力和线程安全画像完全相同,_vfprintf_r 同样会碰 _dtoa_r / _localeconv_r / _malloc_r。
  • 不要同时用 printf 和 snprintf,会链接两份约 7 KB 的副本。
  • 把 vsnprintf 换成 printf 不解决任何线程安全问题,只是换了输出目标。

printf 里的 malloc 什么时候才走

反汇编 _svfiprintf_r 开头:

ldrh r3, [r1, #12]    ; fp->_flags
ldr  r3, [r1, #16]    ; fp->_bf._base,__sFILE 偏移 0x10
cmp  r3, #0
beq  malloc_64        ; 只有 _bf._base == NULL 才 malloc(64)
用法会不会 malloc
printf(...) 到 stdout(初始无缓冲)会,malloc(64)
vsnprintf(buf, n, ...)不会,用调用者缓冲

排查命令

# 整体占用
arm-none-eabi-size --format=berkeley build/xxx.elf

# 按大小排序找大户
arm-none-eabi-nm -S --size-sort build/xxx.elf

# 确认某函数被谁拉进链接
Select-String -Path build/xxx.map -Pattern "(vsnprintf)"

# 看 C 库内部依赖关系(最有价值的手法)
$tc  = "<工具链根目录>"
$lib = "$tc/arm-none-eabi/lib/thumb/v8-m.main/nofp/libc.a"
& "$tc/bin/arm-none-eabi-ar.exe" t $lib | Select-String "printf|dtoa|mprec"
& "$tc/bin/arm-none-eabi-ar.exe" x $lib "lib_a-svfiprintf.o"
& "$tc/bin/arm-none-eabi-nm.exe"      "lib_a-svfiprintf.o" | Select-String "^\s*U "
& "$tc/bin/arm-none-eabi-objdump.exe" -d "lib_a-svfiprintf.o"   # 看 sub sp,#N 得栈帧
想判断看什么
格式化函数是否依赖浮点未定义符号里有没有 _dtoa_r
函数栈帧多大反汇编开头的 sub sp, #N
某分支何时触发找跳到该地址的所有 b*,看前面的比较条件

记忆卡

场景记住这句话
Keil 为什么没这个问题编译器换了,C 库也换了,根本不链接 newlib
printf 会不会调 vsnprintf不会,同一份 vfprintf.c 编译出的两份独立副本
任务间打印安全吗默认不安全,C 标准 7.1.4 明确不保证可重入
加互斥锁够吗任务间够,但 ISR 里不能打印,锁也不解决实时性阻塞
reent 隔离和 StreamBuffer 是一回事吗不是,前者防内部状态竞争,后者防输出交错
要不要用 %f尽量不用,改定点转字符串加 %s
想减少依赖该改什么换掉格式化器的实现,不是改函数签名、不是搬队列

术语表

术语含义
可重入函数未返回时被再次调用仍能正确工作
线程安全多个执行流并发调用不互相破坏
struct _reentnewlib 的线程状态结构体,是结构体不是函数
_impure_ptr指向当前执行流 _reent 的全局指针
_r 后缀newlib 可重入版函数的命名约定,首参为 _reent *
library spaceArm C 库存放静态状态的区域,类比 newlib 的 reent
libnosys.a提供返回 ENOSYS 的系统调用桩
specs 文件GCC 的链接配置片段,决定用哪套 C 库
--gc-sections链接器选项,丢弃未被引用的 section