
1. 从一次真实的编译翻车说起搞嵌入式开发的朋友尤其是玩STM32和51单片机的对KEIL5这个IDE可以说是又爱又恨。爱的是它生态成熟、芯片包齐全、调试器支持到位恨的是它时不时给你甩一个莫名其妙的链接错误让你对着屏幕怀疑人生。我印象特别深的一次是帮一个学弟看他移植过来的工程代码逻辑明明没问题编译单个文件也过了一到链接阶段就蹦出来一行红字Error: L6200E: Symbol __stdout multiply defined。他当时一脸懵地问我“__stdout是啥我代码里根本没写过这个东西啊。”这个报错其实在KEIL5的圈子里非常典型尤其是当你把两个原本独立的工程合并、或者把标准库和HAL库混着用、又或者从别人那里拷了一份带printf重定向的代码进来的时候L6200E几乎必然出现。它的本质是符号重定义而__stdout这个符号之所以特殊是因为它牵扯到C标准库的底层I/O实现也就是我们常说的“printf重定向”。很多人只知道要写fputc却不知道__stdout这个结构体指针在背后扮演了什么角色更不知道不同的库配置下它的定义方式完全不同。这篇文章我打算把L6200E和__stdout这件事彻底讲透。不管你是刚装完KEIL5、还在研究怎么新建STM32工程的新手还是已经能熟练烧录、但一遇到链接错误就头大的老手都能从里面找到能直接抄作业的解决方案。我会从报错的底层原理讲起拆解__stdout到底是谁定义的、为什么会被定义两次然后给出三套不同场景下的修复方案最后把我这些年踩过的坑和排查技巧整理成一张速查表。内容偏实操代码和配置都能直接复现你照着做基本就能把这个问题按死。2. L6200E与__stdout的底层逻辑拆解2.1 L6200E到底在说什么L6200E是ARM链接器armlink抛出的错误全称是“Symbol multiply defined”翻译过来就是“符号被多次定义”。链接器的工作是把一堆.o目标文件和.lib库文件拼成一个可执行文件每个符号函数名、全局变量名在整个程序里只能有一个“强定义”。如果链接器发现同一个符号在两个地方都被定义了而且都不是弱定义它就会报L6200E然后告诉你这个符号叫什么、分别在哪个文件里被定义。这里有个关键点L6200E是链接期错误不是编译期错误。也就是说你的每个.c文件单独编译都是通过的编译器不会管__stdout有没有重复它只管语法和单个翻译单元内的符号。只有到了链接阶段把所有目标文件汇总的时候冲突才会暴露出来。这也是为什么很多人觉得这个错误“莫名其妙”——明明每个文件都没问题合起来就炸了。报错的完整格式通常长这样.\Objects\project.axf: Error: L6200E: Symbol __stdout multiply defined (by main.o and retarget.o).括号里会明确告诉你冲突发生在哪两个目标文件之间。这个信息极其重要它是你排查问题的第一线索。很多人看到报错就慌直接去网上搜其实先看清楚括号里的两个文件名问题就解决了一半。2.2 __stdout是什么为什么它会重定义__stdout是ARM C标准库ARM Compiler的microlib或标准库内部定义的一个全局变量类型是FILE结构体指针。它的作用是给标准输出提供一个“文件句柄”printf、puts这些函数最终都会往__stdout指向的地方写数据。在PC上__stdout默认指向控制台在单片机上没有控制台所以需要你通过“重定向”把它指到串口、LCD或者别的输出设备上。问题的根源在于__stdout的定义方式取决于你用的是标准库还是microlib以及你有没有自己定义它。在ARM标准库非microlib里__stdout是在库内部已经定义好的你不需要也不应该自己定义它。你只需要实现fputc函数库会自动调用你的fputc来输出字符。这种情况下如果你在代码里又写了一遍FILE __stdout;或者struct __FILE __stdout;链接器就会发现库里有定义、你的目标文件里也有定义于是报L6200E。而在microlib里情况反过来了。microlib为了极致精简默认不包含完整的stdio支持__stdout需要你自己定义。如果你用了microlib却没定义__stdout会报另一个错误通常是L6218E undefined symbol。但如果你既用了microlib又在两个不同的.c文件里都定义了__stdout那照样是L6200E。还有一种常见情况你从网上抄了一份printf重定向代码里面带了FILE __stdout;然后你又从另一个工程拷了一份类似的代码两份都进了编译冲突就来了。或者你用的某个第三方库比如某些RTOS的调试组件、某些LCD驱动库内部也定义了__stdout和你自己的重定向代码撞车。2.3 标准库与microlib的配置差异要彻底搞懂这个问题必须弄清楚KEIL5里那个“Use MicroLIB”勾选框到底干了什么。这个选项在Options for Target→Target选项卡里勾上就是用microlib不勾就是用ARM标准库。对比项ARM标准库MicroLIB代码体积较大极小专为嵌入式优化__stdout定义库内部已定义需要用户自己定义printf支持完整精简需重定向浮点printf支持默认不支持需额外配置适用场景资源充足、需要完整C库Flash/RAM紧张的单片机重定向写法只写fputc写fputc 定义__stdout这张表是排查L6200E的核心依据。很多人出问题就是因为没搞清楚自己用的是哪个库然后重定向代码写错了版本。比如用标准库却定义了__stdout或者用microlib却定义了两遍__stdout。提示KEIL5默认新建工程时MicroLIB是不勾选的。但很多网上的STM32工程模板为了省空间会勾上你移植的时候如果没注意这个选项就很容易出问题。2.4 为什么printf重定向会牵扯到__stdout这里稍微展开讲一下printf的调用链理解了这条链你就明白为什么__stdout这么关键。当你调用printf(hello)时标准库内部大致会走这么几步先把格式化后的字符串准备好然后调用fputc或者更底层的__write把字符一个个送出去。而fputc的第一个参数是字符第二个参数是FILE *指针这个指针就是__stdout。库内部在初始化的时候会把__stdout指向标准输出流printf默认就往这个流里写。在标准库模式下__stdout由库自己维护你只需要提供fputc的实现库会通过__stdout找到你的fputc。在microlib模式下库不提供__stdout你得自己定义一个FILE结构体变量并命名为__stdout同时提供fputc。如果你定义了两个__stdout链接器就懵了——它不知道该用哪个。所以L6200E的本质不是“你写错了代码”而是“你提供了多余的、重复的底层符号定义”。解决思路也就清晰了要么删掉多余的定义要么统一库配置让__stdout只有一个来源。3. 三套修复方案与实操步骤3.1 方案一标准库模式下的正确重定向写法如果你确认自己的工程用的是ARM标准库MicroLIB没勾选那么正确的printf重定向代码应该长这样#include stdio.h // 只实现fputc不要定义__stdout int fputc(int ch, FILE *f) { // 假设用USART1发送 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }注意这里没有FILE __stdout;这一行。标准库内部已经定义好了__stdout你只需要提供fputc库会自动把__stdout和你的fputc关联起来。如果你之前的代码里有FILE __stdout;或者struct __FILE __stdout;直接删掉。然后重新编译L6200E应该就消失了。但这里有个坑有些老版本的教程会教你写struct __FILE { int handle; };然后再定义FILE __stdout;这是针对非常老的ARM编译器或者特定库版本的写法。在KEIL5 MDK的较新版本里标准库已经不需要你这么做了。如果你照抄了这种老代码又没勾microlib就会直接触发L6200E。注意删掉__stdout定义后如果报L6218E undefined symbol __stdout说明你其实用的是microlib只是没勾选或者勾选状态和你以为的不一样。这时候要么勾上MicroLIB并保留定义要么检查库配置。3.2 方案二MicroLIB模式下的正确重定向写法如果你勾选了MicroLIB那么__stdout需要你自己定义。正确的写法是#include stdio.h // microlib下需要自己定义__stdout FILE __stdout; int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }关键就是那一行FILE __stdout;。在microlib下这一行是必须的而且只能出现一次。如果你在两个.c文件里都写了这一行就会L6200E。排查的时候用编辑器的“在文件中查找”功能全局搜索__stdout看看它出现在几个地方。正常情况下应该只有一处定义。如果发现两处删掉其中一处保留和fputc在同一个文件里的那个。还有一种情况你用的某个第三方库比如某些LCD驱动、某些调试组件内部也定义了__stdout。这时候你不能直接删库里的代码得换个思路——要么把库的这部分功能关掉要么把你的重定向代码改成不定义__stdout改用库提供的接口。具体怎么处理要看库的文档但核心原则不变整个工程里__stdout只能有一个定义。3.3 方案三混合工程与多文件冲突的排查流程最麻烦的情况是工程里文件很多你根本不知道__stdout在哪儿被定义了两次。这时候需要一套系统的排查流程。第一步看报错信息里的括号。比如(by main.o and retarget.o)说明冲突在main.c和retarget.c之间。直接去这两个文件里搜__stdout大概率能找到两处定义。第二步如果报错信息里出现的是.lib文件比如(by main.o and microlib.lib)说明你的代码和库冲突了。这通常意味着你用了标准库却自己定义了__stdout或者用了microlib但库版本不对。检查MicroLIB勾选状态然后按方案一或方案二调整。第三步如果报错信息里两个都是.o文件但你搜遍代码都找不到__stdout那可能是某个头文件里藏了定义或者某个宏展开后产生了定义。这时候用编译器的“预处理”功能把.c文件预处理成.i文件然后搜.i文件里的__stdout就能看到宏展开后的真实代码。第四步如果以上都找不到检查是不是链接了重复的库文件。在Options for Target→Linker选项卡里看看有没有手动添加了额外的库或者库路径里有多个版本的同一个库。我整理了一个排查顺序表照着走基本能定位步骤操作预期结果1读报错括号里的文件名定位冲突的两个目标文件2在这两个文件里搜__stdout找到重复定义3检查MicroLIB勾选状态确认库模式4全局搜索__stdout排除隐藏定义5预处理后搜索排除宏展开6检查Linker选项卡排除重复库3.4 实操现场一次真实的修复记录说一个我最近处理的案例。一个朋友做STM32F103的项目用的是标准库不是HAL工程里有一个usart.c负责串口初始化一个main.c负责主逻辑还有一个从网上下的printf.c负责重定向。编译报错Error: L6200E: Symbol __stdout multiply defined (by printf.o and main.o).我让他先搜__stdout结果发现printf.c里有FILE __stdout;main.c里也有一行FILE __stdout;。原来他之前调试的时候在main.c里随手加了一行后来忘了删。把main.c里那行删掉只保留printf.c里的定义重新编译问题解决。但这里有个细节他的工程其实没勾MicroLIB用的是标准库。标准库下根本不需要定义__stdout所以正确做法是把两处__stdout都删掉只保留fputc。我让他这么改之后编译通过串口输出也正常。这个案例说明很多人是“多此一举”地定义了__stdout而不是“缺少定义”。标准库模式下你越少动底层符号越好。4. 常见问题与排查技巧实录4.1 L6200E和L6218E的区别与联系这两个错误经常成对出现很多人搞混。简单说L6200E符号被定义了多次multiply defined是“多了”。L6218E符号未定义undefined symbol是“少了”。它们的关系很微妙。比如你从标准库切换到microlib如果忘了定义__stdout就会从“没有L6200E”变成“L6218E”。反过来你从microlib切到标准库如果没删掉__stdout定义就会从“正常”变成“L6200E”。所以处理这类问题时先确认库模式再决定动不动__stdout顺序不能反。我见过有人一上来就删__stdout结果从L6200E变成了L6218E然后又开始加定义来回折腾。4.2 为什么删了__stdout还是报错有时候你明明删掉了代码里的__stdout重新编译还是L6200E。这种情况通常是以下几个原因一是没有重新编译全部文件。KEIL5有时候会增量编译只编译改动的文件旧的.o文件还留在Objects目录里。解决办法是Project→Rebuild all target files强制全部重新编译。二是**.o文件残留**。手动去Objects目录把旧的.o和.axf删掉再重新编译。这个目录有时候会有缓存尤其是你改过库配置之后。三是库文件冲突。你工程里可能链接了两个不同版本的C库比如同时有标准库和microlib的残留。检查Options for Target→Linker里的库配置确保只链接一个。四是头文件里的定义。有些第三方头文件里藏了FILE __stdout;你搜.c文件搜不到。用全局搜索CtrlShiftF搜整个工程目录包括头文件。4.3 多文件工程中如何优雅地管理printf重定向对于文件多的工程我建议把printf重定向单独放在一个文件里比如retarget.c然后在这个文件里统一处理库模式的兼容问题。可以这么写#include stdio.h // 根据库模式自动适配 #ifdef __MICROLIB FILE __stdout; #endif int fputc(int ch, FILE *f) { // 你的串口发送代码 return ch; }__MICROLIB是KEIL5在勾选MicroLIB时自动定义的宏。用这个宏做条件编译就能让同一份代码在两种库模式下都正确工作。勾了MicroLIB就定义__stdout没勾就不定义完美避开L6200E和L6218E。这个技巧我从一个老工程师那里学来的用了好多年非常稳。尤其是做跨平台移植或者给别人提供代码模板的时候加上这个条件编译能省掉大量“你那边报什么错”的沟通成本。4.4 排查速查表与避坑清单最后把常见场景和对应处理整理成一张表遇到问题直接查现象可能原因处理方法L6200E括号里两个.o两个.c都定义了__stdout删掉一个保留与fputc同文件的L6200E括号里有.lib标准库下自己定义了__stdout删掉__stdout定义L6218E缺__stdoutmicrolib下没定义__stdout加上FILE __stdout;删了还报错增量编译残留Rebuild all删Objects目录搜不到__stdout头文件或宏展开预处理后搜索全局搜索换库后报错库模式与代码不匹配用__MICROLIB宏条件编译提示每次修改库配置勾选/取消MicroLIB之后一定要Rebuild all不要相信增量编译。这是血泪教训。另外几个避坑点不要从多个来源拷贝重定向代码统一用一份不要在头文件里定义__stdout头文件会被多个.c包含必然重定义如果用了RTOS注意RTOS的调试组件可能也定义了__stdout需要协调。我个人在实际操作中的体会是L6200E这类链接错误看着吓人其实只要理解了“符号只能有一个强定义”这个原则再结合报错信息里的文件名排查起来并不难。真正难的是那些报错信息不明确、或者冲突藏在库里的情况这时候就需要耐心地一层层剥。我一般会先把库模式确认清楚然后全局搜符号再不行就预处理看展开结果基本三步之内能定位。这套方法不光适用于__stdout其他L6200E符号冲突也能照搬。