为何重定义库函数会减少flash体积(从prinf讲解)
备注:使用keil的microlib库进行实验,printf的底层实现是通过fputc()来实现。
1. 为什么要重定义 fputc
默认实现:在 ARM/Keil Microlib 里,
printf
等最终都会调用fputc
。如果用户没提供,库里的fputc.o
会被用上。库版 fputc 的问题:
它默认走 半主机 (semihosting),通过调试器把输出送到 PC 主机。
脱机运行时可能触发 HardFault(因为 BKPT/调试协议无人响应)。
它依赖
semi.o
、iusesemip.o
等模块,把一坨用不到的代码拖进来,增大 Flash 占用。
重定义的好处:
把输出重定向到 UART/SWO/ITM 等实际外设。
避免半主机依赖 → 节省 Flash。
程序可脱机独立运行。
2. 链接器的行为和原理
规则:“谁需要,谁没定义,就从库里拉进来”。
程序用到
printf
→ 未定义符号fputc
。链接器去库里找到
fputc.o
→ 拉进来。fputc.o
又引用了iusesemip.o
、semi.o
→ 一并拉入。
成员粒度:库是以成员
.o
为单位拉取的。一个成员进来了,其中没被引用的 section 还能被“段级 GC”剔除。
但
fputc.o
必然触发半主机桩,因此关键段一定会留下。
结果:Flash 被占用的不是
fputc
函数本身,而是它引入的半主机链条。3.
.map
文件中的表现未重定向时
交叉引用能看到:
意味着:
printf
最终依赖库版 fputc,触发半主机。改写
int fputc(int ch, FILE *f)
,例如用串口发送:已重定向时
改写int fputc(int ch, FILE *f)
,例如用串口发送:
此时fputc.o
、semi.o
、iusesemip.o
不再出现,Flash 立刻减少。4. Flash 体积减少的本质
Flash 变小 ≠ 只是少了一个
fputc
,而是 少了一整条半主机链条。这是一种 粗层次裁剪(成员没拉进来 → 整条依赖没触发),比单靠段级别 GC(细粒度裁剪)更有效。
直观差别:
没重定向:
fputc.o + semi.o + iusesemip.o
→ 几百到上千字节额外占用。重定向:只剩你自己的
fputc
几十字节。
-
5. 常见问题和你的提问对应
Q:如果不定义串口,
printf
默认去哪?
→ 默认走半主机输出(调试器终端),脱机会异常。Q:map 里为什么会出现
semi.o
?
→ 因为库版fputc
的实现强制依赖半主机符号,所以会被拉进来。Q:是不是把整个 semi.o 都拉进来了?
→ 会拉整个成员,但链接器还能剔除其中没用的段;不过半主机必需段一定会保留。Q:Flash 为什么能减小?
→ 不是fputc
自身的大小,而是避免了一整条半主机依赖链。6. 图示对比
未重定向
已重定向
7.注解
1.什么是半主机?那有没有全主机?半主机 (Semihosting):名字里的“半”指的是:嵌入式程序运行在目标 MCU 上,但通过调试器(JTAG/SWD)把部分系统调用转发给 主机 来执行。
常见功能:
fputc/printf
→ 把字符输出到主机调试窗口(最常用)。文件操作 → 在目标机上调用
fopen/fread/fwrite
,其实是在 PC 上读写文件。时间查询、命令行参数等。
**“主机功能”**就是这些标准库 I/O 功能在 PC 机上的完整实现。因为嵌入式 MCU 上没有操作系统/文件系统,所以库默认通过 半主机转发 来模拟
2.对于原始fputc来说,是不是不要半主机功能才认为之前是冗余代码?
1) 什么时候是“刚需”,什么时候是“冗余”
需要半主机(调试时把
printf
输出到 IDE/主机终端、或要文件/时间等主机调用)
→ 库版fputc
走半主机路径,这就必须把半主机运行时拉进来(semi.o
实现、iusesemip.o
特性桩、以及stdout
流控制块等)。这些代码是功能所需,不是冗余。不需要半主机(比如量产固件,仅需 UART 打印)
→ 如果还用库版fputc
,链接器会把半主机链条拉进来,就变成对你的目标来说是冗余;这时改成自定义fputc
/__write
,把这条链从根上剪掉,Flash 自然变小。
2) 为什么“会拉一条链”,而且看起来不止“打一行字那么简单”
printf
不直接发字节,它依赖一个下层钩子fputc
。
你当前这份 map 里,“未重定向”的典型形态是:printf* → fputc.o
(库版),再由库版fputc.o → iusesemip.o / semi.o
来启用半主机:
另外很多printf*
变体也会引用stdout.o(.data)
的__stdout
(标准输出流控制块),这是半主机/流式 I/O 的配套设施:半主机并不只有“输出一个字符”:库方需要提供与调试器通信的胶水代码(BKPT/SWI 调用路径)、错误/状态处理、流对象、以及(若启用)更广泛的 host 调用(文件、时间、命令行参数等)的“入口符号”。
就算段级回收会剔除没被引用的函数,最小可用的那几段也要保留,所以体积不会是 0。你在 map 里也能看到链接器确实裁掉了很多没用段(“Removing Unused input sections … 489 unused section(s)”),但半主机必需段仍在:
3) 为什么“自定义 fputc
”会显著瘦身
一旦你自带
fputc
(比如发 UART),链接器的“未定义符号解析”就不再选择库的fputc.o
;既然fputc.o
没被拉入,它对iusesemip.o
/semi.o
的后续依赖也根本不会触发。这属于**成员级(粗粒度)**的裁剪:整条链没进门,比仅靠段级 GC(细粒度)更有效,Flash 体积自然下降。
相反,如果你就是要半主机,那这条链必须存在,它不是“拖累”,而是功能所需的最小闭包。
4) 一图看差别(同一工程,两种构建意图)
A. 需要半主机(调试到主机终端)
这些模块在你的 map 里都有证据可循:printf* → fputc.o
、fputc.o → iusesemip.o/semi.o
、以及 __stdout
的引用。
B. 不要半主机(量产,仅 UART)
此时再看 map,半主机相关成员应当消失;如果还出现,就说明你某处仍走了库版路径。
5) 小结(给决策用)
你要半主机 →
semi.o/iusesemip.o
就是刚需,不是“拖累”;这时候体积是合理成本。你不要半主机 → 这些模块对你的目标来说就是冗余;重定向
fputc
/__write
(或使用只走内存的vsnprintf
+ 自己发串)能把整条链剪掉,Flash 立减。进一步瘦身:避免
%f/%e/%g
,否则还会把双精度格式化链拉进来(你 map 里_fp_digits
与__aeabi_d*
那串成员就是这块体积)。