一页速查
| 问题 | 答案 |
|---|---|
| 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/O | printf 族、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_data | 1064 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 mbrtowc | shift 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 MDK | GCC |
|---|---|---|
| 编译器 | Arm Compiler 5 / 6 | arm-none-eabi-gcc |
| C 库 | Arm C Library | newlib |
| 精简版 | MicroLIB | newlib-nano |
| 线程隔离 | __user_perthread_libspace | struct _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 _reent | newlib 的线程状态结构体,是结构体不是函数 |
_impure_ptr | 指向当前执行流 _reent 的全局指针 |
_r 后缀 | newlib 可重入版函数的命名约定,首参为 _reent * |
| library space | Arm C 库存放静态状态的区域,类比 newlib 的 reent |
libnosys.a | 提供返回 ENOSYS 的系统调用桩 |
| specs 文件 | GCC 的链接配置片段,决定用哪套 C 库 |
--gc-sections | 链接器选项,丢弃未被引用的 section |