news 2026/9/19 6:59:56

KEIL5 L6200E错误解析:__stdout重定义原因与解决方法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
KEIL5 L6200E错误解析:__stdout重定义原因与解决方法

1. 从一次真实的编译翻车说起

如果你在用KEIL MDK做STM32或者C51的开发,某天加了一个串口打印功能,或者从别人那里拷贝了一段带printf的代码进来,编译的时候突然蹦出来一行红字:

.\Objects\project.axf: Error: L6200E: Symbol __stdout multiply defined (by usart.o and main.o).

第一反应通常是懵的。明明代码里只写了一个printf,怎么__stdout就被定义了两次?而且报错指向的是usart.omain.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.omain.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.omain.o就是冲突的两个目标文件。你需要回到工程里,找到这两个.o对应的.c文件。通常.o文件名和.c文件名是一致的,比如usart.o对应usart.cmain.o对应main.c

如果括号里出现的是.lib文件,比如stdio.lib或者某个第三方库,那说明冲突的一方来自库文件,你需要检查自己代码里是不是也定义了同样的符号。

4.2 第二步:在源文件里搜索__stdout和fputc

打开报错涉及的两个.c文件,用编辑器的搜索功能(Ctrl+F)搜__stdout__stdinfputc_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 definedAC6下重复实现_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,说明是弱符号;如果是DataFunc,说明是强符号。

这个工具在排查复杂的符号冲突时非常有用,尤其是当冲突的一方来自库文件,你没法直接看源码的时候。

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,编译的时候会输出提示信息,帮你确认编译器版本和库类型。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 6:59:33

MindSpore范式重构:从代码编写到意图声明的AI开发革命

1. 这不是一次简单的框架升级&#xff0c;而是一场开发范式的迁移“MindSpore的跨界范式重构”——看到这个标题&#xff0c;很多老用户第一反应可能是&#xff1a;又一个AI框架的版本迭代&#xff1f;加了几个新算子&#xff1f;优化了点训练速度&#xff1f;但如果你真这么想…

作者头像 李华
网站建设 2026/9/19 6:59:30

用WorkBuddy搭建7×24小时AI投研团队:岗位设计到落地复盘

写今天这篇之前&#xff0c;我刚结束一天的盯盘和复盘。说实话&#xff0c;一个人做投研最累的不是分析&#xff0c;而是那些绕不开的重复劳动&#xff1a;早上翻隔夜市场、白天盯公告和新闻、晚上拆财报、深夜还要写纪要。一个月前&#xff0c;我把这套活儿交给了用 WorkBuddy…

作者头像 李华
网站建设 2026/9/19 6:58:37

轮腿机器人定点排雷:亚厘米定位与毫米级力控实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 6:57:35

STM32+AD7606 SPI驱动优化:从阻塞查询到DMA流水线,实现60kSPS采样

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 6:57:32

从零开发AI聊天App:支付、官网与上架全记录

三月底那阵子&#xff0c;我整个人处于一种很奇怪的状态。白天上班开会还能正常应对&#xff0c;一到晚上就瘫在沙发上刷手机&#xff0c;刷到脑子发麻也不想睡。焦虑这种情绪最麻烦的地方不是“难受”&#xff0c;而是“不知道自己在难受什么”。为了把自己从这种状态里拽出来…

作者头像 李华