
1. 从一堆源码到两个文件LIB库到底解决了什么麻烦我在做嵌入式项目的时候最烦的不是写代码而是每次交付都要把一堆.c和.h文件打包发给客户或者同事。发少了编译报错发多了核心算法就暴露了。后来我把这部分代码编译成.lib静态库只给对方一个.lib加几个头文件对方能调用但看不到我里面的实现逻辑。这就是 Keil MDK 生成 LIB 库最直接的价值。需要先说清楚一个概念.lib是静态链接库不是动态库。它跟你在 PC 上见到的.soLinux或.dllWindows完全不是一回事。静态库的本质是一批已经编译好的.o目标文件的集合链接器在最终生成.axf或.hex的时候会把库里被引用到的目标文件复制进你的工程。所以程序运行时不需要额外加载库文件库的代码已经变成你固件的一部分了。这一点非常重要因为它决定了后面所有的配置逻辑。那什么时候该做 LIB 库我总结了几种典型场景核心算法不想开源比如你自己调的滤波算法、PID 整定逻辑、加密校验流程这些是心血不想让第三方看到实现细节但对方又必须能调用。模块复用一块屏幕驱动、一个传感器驱动在五六个项目里反复用。每次复制源码容易改乱做成库之后统一版本管理。多人协作减少编译时间大型工程里某个模块很少改动把它固化成一个库其他人在日常编译时只需要链接不用重新编译那几百个文件编译速度肉眼可见地变快。交付中间件给甲方交付功能模块只给接口定义和库文件责任边界清晰。反过来说也有不适合做库的情况。如果你的模块经常要改而且改了之后要别人跟着重新集成那做成库反而增加沟通成本——每次改动都要重新出一个.lib发给所有人。还有一种情况是模块里大量使用了宏定义、内联函数、条件编译#ifdef这些在预处理阶段就要展开的东西做成库之后对方在头文件里改宏往往达不到预期效果因为编译库的时候宏已经固化进去了。这个坑我在后面会专门讲。理解了这个定位后面关于怎么建一个专门生成库的工程哪些文件该进库、哪些不该进头文件怎么设计这些问题逻辑就顺了。库工程和普通应用工程在 Keil 里其实是同一套东西区别只在于输出目标工程希望产出.lib而不是可执行文件。所以第一步要解决的就是让 Keil 知道我这个工程是要产库的而不是产.axf。还有一个容易被忽略的前提编译器版本必须匹配。Keil MDK 不同版本用的 ARM Compiler 不一样AC5 和 AC6 差异巨大后面单独讲。用新版本编译器编出来的.lib在老版本 Keil 里很可能链接不过报一堆符号找不到。所以给别人的库最好问清楚对方用的 Keil 版本或者干脆在交付文档里写明本库使用 ARM Compiler 6.19 编译。2. 建一个只为产库的工程目标配置里的关键开关很多人第一次做库思路是先写好一个正常工程能编译能跑然后想把输出改成.lib结果翻遍设置找不到入口。其实入口就在Options for Target魔术棒→ Output 选项卡里但有个前提你得先选中Create Library这个单选项而不是默认的Create Executable。2.1 新建库工程还是改造现有工程我的建议是新建一个独立的库工程不要在一个本来要出固件的工程里直接改成库。原因很现实库工程不应该有main函数。如果原来工程有main改成库之后虽然能编过但会产生一个多余的main符号别人链接时可能和他的main冲突。库工程不应该包含启动文件startup_xxx.s。启动文件里有复位向量表那是可执行程序才需要的东西。库里面带启动文件链接时会出现重复定义的向量表。库工程不需要链接脚本.sct分散加载文件。分散加载是给最终可执行文件分配内存用的库本身不占内存它只是代码和数据的集合。所以正确的做法是新建一个工程芯片选一个和最终使用场景一致的型号其实芯片型号对纯算法库影响不大但如果库里有外设寄存器操作型号就必须一致或兼容。然后把要封装的.c和.h加进来不要加main.c不要加启动文件。2.2 Output 选项卡逐项确认打开魔术棒切到 Output 页需要确认的东西我列一下配置项应该怎么设为什么Create Executable不勾这是默认项产.axf不是我们要的Create Library勾选产.lib这是核心开关Create HEX File不勾库不产 hex勾了也没意义Browse Information建议勾方便看符号信息排查也挺有用输出目录自己指定别用默认默认混在 Objects 里容易和中间文件搞混库文件名自己起别叫默认名建议带模块名和版本如myfilter_v1_0.lib提示Create Library勾上之后Create HEX File会自动变灰这是正常的说明 Keil 已经进入产库模式。一个很实在的经验库文件名一定要带版本号。我吃过亏同一个项目的两个人手里各有一份dsp.lib一个是一周前的旧版本一个是新的但名字一模一样结果联调时死活对不上查了半天才发现是库版本不一致。带上_v1_2这种后缀或者至少加上日期能省掉很多无谓的扯皮。2.3 关于输出目录的坑默认情况下 Keil 把输出放在工程目录下的Objects\文件夹里中间文件.o、.crf、.d也都在那。库文件名如果和某个中间文件重名虽然不常见但排查起来很头疼。我的习惯是在工程根目录下建一个Lib_Output\文件夹专门放库在Listings\放.map和.lst输出目录用相对路径.\Lib_Output\这样整个工程拷贝到别的电脑上路径也不会失效。绝对路径是另一个高频坑。有些人习惯用D:\project\lib\这种绝对路径在自己机器上没问题一旦工程发给别人Keil 找不到输出目录可能直接编译失败或者把文件放到奇怪的地方。所有路径尽量用相对路径这是工程可移植的基本功。3. 头文件才是库的脸面接口设计决定别人能不能用顺库能不能被顺利使用九成取决于头文件设计得好不好。.lib本身是个黑盒别人唯一的入口就是你给他的.h。我见过太多人把库做得功能没问题但头文件写得一塌糊涂调用者光是搞清楚怎么传参就要花半天。3.1 一个头文件只放对外的东西这是最核心的原则头文件里只应该出现你希望调用者看到的内容包括函数声明对外接口必要的类型定义结构体、枚举、typedef必要的宏定义配置项、返回值定义必要的全局变量声明extern而这些东西不应该出现在交付的头文件里内部使用的辅助函数声明内部数据结构的完整定义如果不想暴露实现可以用不透明指针内部使用的宏任何static函数这些本来就不导出我的做法是把头文件分成两类一类是xxx.h对外一类是xxx_internal.h内部只在自己的.c里 include。对外头文件尽量精简内部头文件随便写。这样交付时只给前者。3.2 不透明指针想彻底藏住实现细节的进阶玩法如果你连结构体成员都不想暴露可以用不透明指针opaque pointer。做法是在对外头文件里只声明类型不定义内容/* myfilter.h —— 对外头文件 */ #ifndef MYFILTER_H #define MYFILTER_H typedef struct my_filter_s my_filter_t; /* 只声明不定义 */ my_filter_t* my_filter_create(int order); int my_filter_init(my_filter_t *f, const float *coeff, int len); float my_filter_process(my_filter_t *f, float input); void my_filter_destroy(my_filter_t *f); #endif然后在库内部的.c文件里才真正定义struct my_filter_s的内容。调用者拿到的是一个指针只能通过接口函数操作完全不知道里面有几个成员、怎么排布的。这种设计在商业交付里非常常见既保护了实现又让接口干净。不过要提醒一句用不透明指针意味着调用者不能把这个结构体放在栈上或者作为别的结构体成员只能动态创建或者提供一块静态缓冲区。嵌入式里很多人不喜欢动态内存那就可以额外提供一个my_filter_get_size()接口返回所需字节数调用者自己分配静态 buffer 传进来。这些都是设计时要提前想清楚的取舍。3.3 数据类型统一别让int和int32_t混着来库工程和使用方工程可能由不同的人维护如果头文件里直接用int、long这种在某些编译选项下长度可能不一样。嵌入式里强烈建议统一使用stdint.h里的定长类型uint8_t、int16_t、uint32_t、int32_t。这样无论对方工程怎么设置接口的二进制布局都是确定的。同样地float和double的选择也要注意。很多 Cortex-M 芯片比如 M0、M3没有硬件双精度浮点用double会调用软件浮点库体积和速度都很难看。接口里能用float就别用double。3.4 命名前缀防止符号冲突.lib链接时所有的全局符号都会进入符号表和被链接工程里的符号放在一起。如果你的库函数叫init()、process()、read()这种大众名字极有可能和别人的函数重名链接时报 multiply defined symbol。所以所有对外函数都加一个独特前缀比如myfilter_init、myfilter_process。这个习惯在团队协作里是刚需。还有全局变量也一样。库里的全局变量如果名字太普通同样会冲突。能用static就用static——static全局变量在库外面是看不见的只有extern声明的才会暴露。所以库内部尽量少用非 static 全局变量这也是一种封装。4. 在应用工程里用起这个 LIB添加、链接与路径配置库做好了接下来就是怎么在真正的项目里用它。这一步看着简单但配置漏一项就报一堆错我按顺序把每个动作说清楚。4.1 把 .lib 加进工程的正确姿势在 Keil 里给工程添加.lib文件有两种方式效果略有不同方式一作为普通文件加进 Group在 Project 窗口右键某个 Group → Add Existing Files文件类型下拉里选Library files (*.lib)然后选你的.lib。这种方式简单直接Keil 会把它作为一个链接输入。方式二在 Linker 里手动指定打开魔术棒 → Linker 选项卡在 Misc controls 里填.\Lib_Output\myfilter_v1_0.lib。这种方式适合路径比较特殊、或者需要精确控制链接顺序的场景。我一般用方式一直观好管理。但有个细节要注意加进去之后Keil 默认不会去编译它库本来就是编译好的它只是作为一个链接目标存在。你在 Project 窗口里看到它的图标和.c文件不一样点它也不会展开源码。4.2 Include Paths 必须配否则编译直接挂这是新手最常卡的地方。你的代码里写了#include myfilter.h但 Keil 不知道myfilter.h在哪就会报cannot open source input file。解决办法把库的头文件放到工程目录下某个文件夹比如.\Lib\Inc\打开魔术棒 → C/C 选项卡 → Include Paths 右边的...按钮把.\Lib\Inc\这个路径加进去。注意Include Paths 里填的是目录不是具体文件。填成.\Lib\Inc\myfilter.h是错的Keil 会当成目录去找。如果你的库有多个头文件放在不同子目录每个目录都要单独加。建议把对外头文件集中在一个Inc目录里别散落各处。4.3 库里的头文件用的是相对路径对方工程怎么找这里有个很隐蔽的问题。库在自己工程里 include 头文件的时候可能写的是#include myfilter.h同目录。但这个路径关系在对方工程里可能不成立。两种处理办法库里统一用#include myfilter.h这种不带路径的写法然后在两边工程的 Include Paths 里各自配好目录。这是推荐做法。或者在库里写#include ../Inc/myfilter.h这种相对路径但这要求对方工程的文件层级和你的完全一致非常脆弱不推荐。所以设计库的时候头文件之间的相互引用尽量用不带路径的裸文件名把路径问题交给 Include Paths 解决。5. 那些让人抓狂的链接错误逐条排查与根因分析库用起来之后报错基本都集中在链接阶段。我把最常见的几类错误按出现频率排一下然后说清楚每一类的根因和验证方法。5.1 未定义符号Undefined symbol典型报错Error: L6218E: Undefined symbol myfilter_init (referred from main.o).意思是main.o里调用了myfilter_init但链接器在所有输入里找不到这个符号的定义。可能的原因有这么几条可能原因排查方法解决.lib没加进工程Project 窗口看有没有这个库文件添加进去.lib加了但没参与链接看 Linker 里的 Misc 是否被别的设置覆盖检查链接配置函数在库里不是全局的库源码里函数被static修饰了去掉static声明和定义签名的 C 名字修饰不一致头文件没加extern C加extern C包裹最后这条在 C 工程里特别常见。如果你的库是 C 写的但使用方是 C 工程那么 C 编译器会对符号名做name mangling名字修饰myfilter_init可能被修饰成_Z13myfilter_initv之类和库里的myfilter_init对不上就报未定义。解决办法是在库的头文件里加#ifdef __cplusplus extern C { #endif /* 你的所有函数声明都在这里 */ #ifdef __cplusplus } #endif这个extern C包裹只要你希望库能被 C 工程调用就一定要加。纯 C 工程加不加无所谓但加上没坏处。5.2 重复定义Multiply defined symbol典型报错Error: L6200E: Symbol my_var multiply defined (by app.o and mylib.lib).这是库和应用工程里有同名符号。常见于库里的全局变量名和应用里的重名库头文件里定义了变量不是声明比如int my_config 0;写在了.h里被多个.cinclude每个都生成一份定义应用工程也加了库的源文件同时有源码和库符号自然重复。头文件里定义变量是大忌。头文件里应该只有extern int my_config;这样的声明真正的定义放在某一个.c文件里。这个规则即使不做库也成立但做库的时候尤其要注意因为库的头文件会被更多地方引用。如果应用工程里既有.c又有.lib那一定是配置错了——要么用源码要么用库不能同时加。这个问题我在接手别人项目时碰到过好几次表现就是一堆重复定义把多余的源文件移除就好了。5.3 库文件和编译器版本不匹配典型报错可能五花八门有时是链接错误有时是更早的解析错误。根源在于ARM Compiler 5AC5也就是 armcc和 ARM Compiler 6AC6基于 clang生成的库格式和 ABI 不完全兼容。判断你用的是哪个魔术棒 → Target 选项卡 → ARM Compiler 那一栏显示Use default compiler version 5还是6。或者看编译输出信息里的版本号。AC5 编译的.lib用 AC6 工程链接很可能报错或行为异常。AC6 编译的.lib用 AC5 工程链接同理。解决方向有两个统一编译器版本让库和使用方用同一个大版本。这是最稳妥的。交付库的时候在文档里写明编译器版本。用 AC6 重新编译库新项目尽量往 AC6 迁移因为 Keil 新版默认就是 AC6AC5 逐渐淡出。还有个细节AC6 默认使用 GNU 风格的扩展某些 AC5 能编过的老代码比如一些隐式类型转换、GNU 扩展语法在 AC6 下会报警告甚至错误。库源码迁移到 AC6 时要留出时间处理这些兼容问题。5.4 一个排查顺序表遇到链接错误我通常按这个顺序查最快定位确认.lib确实在工程里且是当前需要的那一份版本对看报错原文的符号名确认拼写和头文件声明完全一致确认库源码里这个函数是全局的没被staticC 工程查extern C确认编译器版本一致实在不行用fromelf工具反查库里的符号fromelf --text -s myfilter_v1_0.lib symbols.txt或者用--symbolsfromelf --symbols myfilter_v1_0.lib这样能看到库里到底导出了哪些符号和你期望的对一下问题基本就现形了。6. 让库真正稳的几条实战心得前面把流程走通了但要让库在长期使用中不惹事还有几个细节值得单独说。6.1 库的调试信息与符号表要不要保留如果库保留调试信息出问题时可以配合.map文件和.lst反汇编定位到库内部虽然看不到源码但能看汇编。如果为了减小体积可以在 Output 里去勾选相关的调试选项。我的建议是发布版本保留必要的符号信息但可以去掉最详细的调试数据因为完整的调试信息会显著增大.lib体积而对最终固件大小影响不大链接器只挑用到的部分。真正影响固件体积的是链接器按需提取的机制.lib里未被引用的目标文件不会被链进去。所以一个库可以放很多功能只要应用没调用到就不会占用空间。这也是静态库相比直接编译所有源码的一个隐形优势。6.2 库内部的全局变量和初始化C 语言里全局变量默认在.bss或.data段程序启动时会由启动代码清零或初始化。库里的全局变量也遵循这个规则只要使用方有正常的启动代码库里的.data和.bss都会被正确初始化。但前提是库被链接进来的部分——如果一个库里定义了int counter 5;但应用没引用任何触发这个目标文件被链接的符号那这个变量就不会被初始化因为它根本没进固件。这一点在排查库里的全局变量初始值不对时要想到。另外库里的构造函数/析构函数C 里的在 C 语言环境一般用不到。C 库要小心静态对象的构造时机这个涉及启动流程比较复杂嵌入式里不推荐在库里放依赖构造函数的全局对象。6.3 版本管理与变更记录库一定要有版本号和变更记录。我在实际项目里维护过一个驱动库半年里改了十几版如果没有记录根本说不清某一版改了什么、为什么改。建议库文件名带版本号dsp_v1_3.lib同时在头文件里用宏标注版本#define DSP_LIB_VERSION_MAJOR 1 #define DSP_LIB_VERSION_MINOR 3 #define DSP_LIB_VERSION_PATCH 0调用者可以在运行时打印这些宏确认自己链接的是哪个版本。这招在联调时特别有用。6.4 头文件里的宏在库编译后失效的问题这是开头提到的那个坑值得展开。假设你的库头文件里有个配置宏#define MYFILTER_MAX_ORDER 16库在编译的时候这个宏是 16库内部根据它分配了缓冲区。调用者如果在自己工程里#define MYFILTER_MAX_ORDER 32之后再 include 头文件他会以为滤波器支持最高 32 阶但库内部实际还是按 16 阶编的运行起来就可能溢出。所以凡是被库内部代码用到的宏都不能指望调用者重新定义。要么把这类宏改成运行时参数用函数传进去要么在头文件里写清楚此宏生效于库编译期调用者请勿重定义。我踩过这个坑一次对方把#define改了之后表现是偶发数据错乱查了很久才想到是宏的问题。从那以后我库里的可配置项全部走函数参数头文件宏只用于版本标识和返回值定义这类不改的东西。6.5 何时应该继续用源码而不是库最后说一句实在话库不是万能的。如果你的模块处于高频迭代期或者调用方需要针对不同项目做大量条件编译那用源码反而更灵活。我自己的判断标准是当一个模块稳定到半年不动或者需要对外保密时才值得做成库。过早做库改一次发一次库反而比直接给源码折腾。对于自己在本地的小项目其实也没必要做库源码直接加进工程最省事。库的真正价值在于跨团队、跨项目、需要控制可见性的交付场景。想清楚这一点就不会为了做库而做库了。