1. 从一次真实的编译翻车说起
如果你在用KEIL MDK做STM32或者C51的开发,某天加了一个串口打印功能,或者从别人那里拷贝了一段带printf的代码进来,编译的时候突然蹦出来一行红字:
.\Objects\project.axf: Error: L6200E: Symbol __stdout multiply defined (by usart.o and main.o).第一反应通常是懵的。明明代码里只写了一个printf,怎么__stdout就被定义了两次?而且报错指向的是usart.o和main.o两个目标文件,看起来像是两个文件里都各自定义了一份标准输出流对象,链接器在合并的时候发现符号冲突,直接罢工。
这个问题的核心关键词就是KEIL5、L6200E、__stdout、重定义。它属于ARM链接器(armlink)报出的符号重复定义错误,跟编译阶段的语法错误完全是两码事。编译阶段每个.c文件单独编译成.o,各管各的,不会互相干扰;到了链接阶段,链接器要把所有.o文件里的符号拼到一起,这时候如果同一个全局符号出现了两次,就会触发L6200E。
这篇文章适合所有正在用KEIL5做嵌入式开发的人看,不管你是刚装完KEIL5、还在折腾芯片包的新手,还是已经能独立建工程、调外设的老手,只要你的代码里用到了printf重定向、fputc重写、或者引入了带标准库输出的第三方驱动,都有可能踩到这个坑。我会把__stdout到底是什么、为什么会重定义、几种典型触发场景、以及每一种场景下怎么改,全部拆开讲清楚,并且给出可以直接抄的代码和配置。
2. __stdout到底是什么,为什么它会重定义
2.1 标准库里的stdout和__stdout的关系
在C标准库里,stdout是一个宏,展开之后通常指向一个FILE类型的结构体指针。而在ARM Compiler的底层实现里,这个指针背后对应的实体对象就是__stdout。换句话说,__stdout是标准输出流在链接层面的实际符号名,stdout只是你在代码里用的那个名字。
当你调用printf的时候,库函数内部会去访问__stdout这个对象,把格式化好的字符串通过它关联的底层写函数送出去。在PC上,这个底层写函数最终落到操作系统的控制台;在单片机上,没有操作系统,这个底层写函数是空的或者是一个弱符号(weak symbol),需要你自己去实现fputc或者_write来把它重定向到串口。
关键点在于:__stdout这个对象本身是由ARM标准库提供的,正常情况下整个工程里只应该有一份。如果你在某个.c文件里自己又定义了一个__stdout,或者你引入的某个库文件里带了一份__stdout,链接器就会发现两份定义,报L6200E。
2.2 为什么链接器不直接选一个用
有人会问,既然两份定义内容可能差不多,链接器为什么不随便挑一个?这是因为C语言的链接模型里,全局符号默认是强符号(strong symbol),强符号出现多次就是未定义行为,链接器必须报错,不能替你做主。只有弱符号(weak symbol)才允许被覆盖。
ARM标准库里的__stdout在库文件中通常是以弱符号形式提供的,这样你才有机会用自己的实现去覆盖它。但如果你自己定义的时候没有注意,或者两个不同的源文件都各自定义了一份强符号,那就直接冲突了。
2.3 L6200E错误的完整含义
L6200E的官方描述是“Symbol multiply defined”,意思是同一个符号在多个目标文件或库文件里被定义了多次。报错信息里会列出所有定义了这个符号的目标文件,比如(by usart.o and main.o),告诉你这两个文件里都有__stdout。
这里要注意一个容易混淆的点:报错里说的usart.o和main.o是编译后的目标文件,不是你的源文件。一个.c文件编译后对应一个.o文件,所以你需要回到对应的.c文件里去找是谁定义了__stdout。
3. 三种典型触发场景与对应解法
3.1 场景一:自己写了__stdout定义,和标准库冲突
这是最常见的情况。很多人在网上搜“KEIL printf重定向”,找到的代码是这样的:
#include <stdio.h> struct __FILE { int handle; }; FILE __stdout; FILE __stdin; int fputc(int ch, FILE *f) { while ((USART1->SR & 0X40) == 0); USART1->DR = (uint8_t)ch; return ch; }这段代码里显式定义了FILE __stdout;和FILE __stdin;。在老版本的ARM Compiler 5(AC5)下,这样写可能没问题,因为那时候标准库对__stdout的处理方式不同。但到了ARM Compiler 6(AC6),或者某些库配置下,标准库已经提供了__stdout的弱定义,你再定义一次强符号,就冲突了。
解法:把FILE __stdout;和FILE __stdin;这两行删掉,只保留fputc的实现。标准库自己会提供__stdout对象,你只需要重写fputc这个底层写函数就行。
如果你用的是AC6,可能还需要把fputc改成_write或者同时提供fputc和_write,具体取决于你选的库是MicroLIB还是标准C库。
3.2 场景二:两个源文件都包含了同一段重定向代码
第二种情况更隐蔽。比如你有一个usart.c负责串口初始化,里面写了printf重定向;后来又在main.c里复制了一份同样的重定向代码,想着“反正每个文件独立编译,应该没事”。结果链接的时候,两个.o文件里都有__stdout或者都有fputc的强定义,直接冲突。
解法:把重定向代码只放在一个.c文件里,其他文件如果需要用printf,只需要包含stdio.h,然后正常调用即可。链接器会自动找到那个唯一的fputc实现。
如果你确实需要在多个文件里都能独立使用串口打印,正确的做法是把重定向代码封装成一个独立的模块,比如retarget.c,然后在头文件里声明接口,其他文件包含头文件即可。
3.3 场景三:第三方库或芯片包自带了重定向实现
第三种情况是你在工程里加入了某个第三方库,比如某个RTOS的移植层、某个中间件的调试模块,它内部已经实现了一份fputc或者__stdout。你自己又写了一份,两份撞车。
这种问题最难查,因为报错信息里列出的目标文件可能不是你直接写的代码,而是库文件编译出来的.o。你需要根据报错信息里的文件名,去工程里找到对应的源文件或者库文件,确认是谁提供了这份定义。
解法:保留一份实现,把另一份删掉或者用条件编译屏蔽掉。如果是库文件里的,可以尝试用链接器的--remove或者--keep选项来控制符号的取舍,但更稳妥的做法还是从源头上只保留一份。
4. 一步步排查L6200E的实操流程
4.1 第一步:读懂报错信息里的目标文件名
KEIL5的Build Output窗口里,L6200E的报错格式通常是这样的:
.\Objects\project.axf: Error: L6200E: Symbol __stdout multiply defined (by usart.o and main.o).括号里的usart.o和main.o就是冲突的两个目标文件。你需要回到工程里,找到这两个.o对应的.c文件。通常.o文件名和.c文件名是一致的,比如usart.o对应usart.c,main.o对应main.c。
如果括号里出现的是.lib文件,比如stdio.lib或者某个第三方库,那说明冲突的一方来自库文件,你需要检查自己代码里是不是也定义了同样的符号。
4.2 第二步:在源文件里搜索__stdout和fputc
打开报错涉及的两个.c文件,用编辑器的搜索功能(Ctrl+F)搜__stdout、__stdin、fputc、_write这几个关键词。通常你会发现至少有一个文件里显式定义了FILE __stdout;,或者两个文件里都定义了fputc。
这里有个小技巧:KEIL5的工程里可以用“Find in Files”功能(Ctrl+Shift+F)在整个工程目录下搜索,比一个一个文件打开快得多。搜索的时候把“Match case”和“Match whole word”都勾上,避免搜到无关内容。
4.3 第三步:决定保留哪一份实现
找到冲突的两份定义之后,你需要决定保留哪一份。原则很简单:
- 如果一份是标准库提供的弱符号,一份是你自己写的强符号,保留你自己的,删掉显式的
FILE __stdout;定义。 - 如果两份都是你自己写的,保留功能更完整的那一份,删掉另一份。
- 如果一份来自第三方库,一份是你自己写的,优先保留你自己的,想办法屏蔽库里的那份。
4.4 第四步:修改代码并重新编译
修改完成之后,不要直接点Build,先点Rebuild(重新编译全部)。因为KEIL5有时候会缓存之前的.o文件,只点Build可能不会重新编译所有文件,导致修改没生效。
Rebuild之后如果L6200E消失了,但出现了新的报错,比如Undefined symbol __stdout,那说明你删多了,把唯一的定义也删掉了。这时候需要把标准库的弱符号恢复回来,或者自己补一个正确的定义。
5. 不同编译器版本下的差异与注意事项
5.1 AC5和AC6对__stdout的处理差异
ARM Compiler 5(AC5)和ARM Compiler 6(AC6)在标准库的实现上有明显差异。AC5用的是传统的ARM C库,__stdout在库里的处理方式比较宽松,很多时候你显式定义FILE __stdout;也不会报错。AC6用的是基于LLVM的新库,符号管理更严格,同样的代码在AC6下就可能报L6200E。
如果你从AC5迁移到AC6,遇到L6200E,第一件事就是检查所有FILE __stdout;和FILE __stdin;的定义,大概率需要删掉。
5.2 MicroLIB和标准C库的选择
KEIL5的Target选项卡里有一个“Use MicroLIB”的勾选项。MicroLIB是ARM专门为嵌入式优化的精简库,体积小,但功能也少。标准C库功能全,但体积大。
这两个库对__stdout的处理也不一样。MicroLIB下,__stdout通常需要你自己定义;标准C库下,__stdout由库提供弱符号。所以如果你在MicroLIB和标准C库之间切换,也可能触发L6200E。
建议:如果你只是用printf做调试输出,MicroLIB足够了,体积还小。如果你需要用到浮点格式化、文件操作等高级功能,再考虑标准C库。
5.3 半主机模式的影响
ARM标准库默认可能启用半主机模式(semihosting),这种模式下printf的输出会通过调试器送到主机,而不是串口。半主机模式也会涉及__stdout的处理。如果你不需要半主机,可以在代码里加上:
__asm(".global __use_no_semihosting\n\t");或者在KEIL5的Linker选项卡里勾选“Use Memory Layout from Target Dialog”并确保没有启用半主机相关的库。
6. 常见问题速查与避坑经验
6.1 常见问题速查表
| 报错现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| L6200E: __stdout multiply defined | 两个文件都定义了FILE __stdout | 搜索工程里的__stdout | 删掉多余的定义,只保留一份 |
| L6200E: fputc multiply defined | 两个文件都实现了fputc | 搜索工程里的fputc | 只保留一个fputc实现 |
| L6200E: _write multiply defined | AC6下重复实现_write | 搜索工程里的_write | 只保留一个_write实现 |
| 删掉__stdout后报Undefined symbol | 删多了,没有替代定义 | 检查是否还有fputc | 补上fputc或恢复库的弱符号 |
| AC5正常,AC6报L6200E | 编译器版本差异 | 确认Compiler版本 | 删掉显式的FILE __stdout定义 |
6.2 避坑经验一:不要盲目复制网上的重定向代码
网上搜到的KEIL printf重定向代码,很多是好几年前写的,针对的是AC5或者更老的版本。直接复制到AC6工程里,大概率会出问题。正确的做法是理解重定向的原理,然后根据自己用的编译器版本和库类型,写对应的实现。
6.3 避坑经验二:重定向代码只放一个文件
我见过不少工程,main.c里有一份fputc,usart.c里又有一份,debug.c里还有一份。问为什么,答曰“每个文件独立,方便”。这种写法在链接阶段必然出问题。正确的做法是建一个retarget.c,把所有重定向相关的代码放进去,其他文件通过头文件引用。
6.4 避坑经验三:注意条件编译的使用
如果你确实需要在不同编译器版本下使用不同的重定向代码,可以用条件编译来区分:
#if defined(__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050) /* AC6的代码 */ int _write(int fd, char *ptr, int len) { for (int i = 0; i < len; i++) { while ((USART1->SR & 0X40) == 0); USART1->DR = (uint8_t)ptr[i]; } return len; } #else /* AC5的代码 */ int fputc(int ch, FILE *f) { while ((USART1->SR & 0X40) == 0); USART1->DR = (uint8_t)ch; return ch; } #endif这样同一份代码在不同编译器下都能正常工作,不会互相干扰。
6.5 避坑经验四:Rebuild而不是Build
修改了重定向相关的代码之后,一定要点Rebuild(重新编译全部),不要只点Build。因为KEIL5的增量编译有时候不会重新编译所有受影响的文件,导致修改没生效,报错依旧。Rebuild虽然慢一点,但能确保所有.o文件都是最新的。
7. 一个完整的可复现示例
7.1 工程结构
假设我们有一个STM32F103的工程,结构如下:
project/ ├── Core/ │ ├── main.c │ └── retarget.c ├── Drivers/ │ └── usart.c └── ...main.c里调用printf,retarget.c里实现重定向,usart.c里只做串口初始化,不涉及重定向。
7.2 retarget.c的完整代码
#include <stdio.h> #include "stm32f1xx.h" /* 声明USART1的句柄,假设在usart.c里定义 */ extern UART_HandleTypeDef huart1; #if defined(__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050) /* AC6下的实现 */ int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } #else /* AC5下的实现 */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; } #endif注意这里没有FILE __stdout;的定义,因为标准库会提供。如果你用的是MicroLIB,可能需要额外处理,但大多数情况下这样写就够了。
7.3 main.c里的调用
#include <stdio.h> #include "stm32f1xx.h" int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); printf("Hello, KEIL5!\r\n"); while (1) { } }7.4 编译验证
Rebuild整个工程,如果一切正常,Build Output里应该没有L6200E,也没有Undefined symbol。烧录到板子上,串口助手应该能看到“Hello, KEIL5!”的输出。
如果还是报L6200E,检查一下是不是还有其他文件里定义了fputc或者__stdout。用Ctrl+Shift+F在整个工程里搜一遍,确保只有retarget.c里有重定向代码。
8. 更深一层:链接器是怎么处理符号的
8.1 强符号、弱符号和Common符号
C语言的链接模型里,符号分为强符号、弱符号和Common符号。强符号是已经分配了存储空间并且有初始值的全局变量或函数;弱符号是声明了但可能没有定义的符号;Common符号是未初始化的全局变量。
链接器的规则是:强符号只能出现一次,多次出现就报L6200E;弱符号可以被强符号覆盖;Common符号可以出现多次,链接器会合并它们。
__stdout在标准库里通常是弱符号,所以你可以用自己的强符号去覆盖它。但如果你自己定义了两个强符号,那就冲突了。
8.2 用fromelf工具查看符号表
如果你想确认某个.o文件里到底有没有__stdout,可以用KEIL5自带的fromelf工具:
fromelf --text -s usart.o > usart_symbols.txt打开生成的usart_symbols.txt,搜索__stdout,就能看到这个符号在.o文件里的类型和地址。如果是Weak,说明是弱符号;如果是Data或Func,说明是强符号。
这个工具在排查复杂的符号冲突时非常有用,尤其是当冲突的一方来自库文件,你没法直接看源码的时候。
8.3 链接器选项--remove和--keep
ARM链接器提供了--remove和--keep选项,可以在链接阶段移除或保留特定的符号。比如:
--remove __stdout这样链接器会尝试移除__stdout符号。但这种方法比较粗暴,可能导致其他依赖__stdout的代码出错。更稳妥的做法还是从源头上只保留一份定义。
9. 我个人的实操体会
这个L6200E的坑,我前前后后踩过好几次。第一次遇到的时候,完全不知道__stdout是什么,只知道报错说重定义了,就把其中一份删掉,结果又报Undefined symbol。后来才慢慢搞明白,__stdout是标准库提供的弱符号,你只需要重写fputc或者_write,不需要自己定义__stdout。
还有一次是从AC5迁移到AC6,同样的代码在AC5下编译通过,在AC6下就报L6200E。查了半天才发现是AC6对符号管理更严格,把显式的FILE __stdout;删掉就好了。
最隐蔽的一次是引入了某个第三方库,它内部已经实现了一份fputc,我自己又写了一份,两份撞车。报错信息里列出的目标文件是库文件的名字,我一开始没意识到那是库文件,还以为是自己的代码。后来用fromelf工具看了符号表,才确认是库里的定义。
所以我的建议是:重定向代码只放一个文件,用条件编译区分AC5和AC6,修改后一定要Rebuild,遇到L6200E先看报错信息里的目标文件名,再用搜索功能定位冲突的源文件。这套流程走下来,基本上能解决90%以上的__stdout重定义问题。
最后再分享一个小技巧:如果你不确定某个符号是不是标准库提供的,可以在KEIL5的工程里右键点击源文件,选择“Options for File”,然后在“C/C++”选项卡里查看预定义的宏。或者直接在代码里加一行#pragma message,编译的时候会输出提示信息,帮你确认编译器版本和库类型。