ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Windows动态链接详解:从DLL导出到VCRUNTIME140.dll实战

Windows动态链接详解:从DLL导出到VCRUNTIME140.dll实战 说实话我在读《程序员的自我修养》前几章的时候心情一直比较舒畅。第2章到第8章把编译、链接、目标文件、ELF、静态链接、动态链接一路讲下来逻辑非常顺畅尤其是Linux下那套GOT/PLT的机制读完真有“原来如此”的通透感。但翻到第9章“Windows下的动态链接”画风一下就变了。导入表、导出表、.lib、LoadLibrary、GetProcAddress、名字改编、DLL搜索顺序……名词一个接一个冒出来而且每条都跟你实际工作里踩过的坑直接挂钩。这一篇就是我的第9篇读书笔记。如果你想搞明白为什么Windows上总提示“找不到VCRUNTIME140.dll”或者为什么你把同事给的DLL拷到别的机器上程序就起不来又或者你需要链接第三方C动态库、写一个DLL给别人调用那么这一章的内容基本就是你避不开的必修课。这篇笔记我按自己的理解重新梳理了一遍尽量把书里偏学院派的叙述转换成实际开发中能用上的经验。1. 第9章到底在讲什么Windows这套动态链接为什么这么别扭1.1 这一章在全书中的位置《程序员的自我修养》整本书的结构说穿了就是带你走一遍“从源码到进程”的全过程先讲编译和链接再讲目标文件然后是静态链接接着花大量篇幅讲Linux下的共享库最后才轮到Windows的动态链接。第9章相当于把前面Linux那套知识平行地映射到Windows平台上。它没有重新讲PE结构因为第5章已经讲过COFF和PE了这一章的重点是“动态链接机制”本身Windows的DLL到底是怎么被加载的导出导入表怎么工作程序是怎么在运行期找到函数的。我读完最大的感受是Windows和Linux在“动态链接”这件事上目标完全一致但实现路径差异极大。Linux讲究位置无关代码PIC程序里的外部调用统一走GOT/PLT间接跳转Windows则更依赖“装载时重定位”和“导入地址表IAT”编译器在生成DLL时会直接预留一个叫IAT的表链接器在加载时把外部函数地址填进去。理解了这条主线后面所有细节其实都是在解释“微软为什么不按Linux那套来”。1.2 Windows与Linux的动态链接对比很多人第一次接触DLL会感到混乱一个很重要的原因是没有把ELF和PE的差异建立起来。我建议在继续往下读之前先做一次表格级对比把两套概念的对应关系捋清楚。我根据书里的内容和自己的理解整理了下表概念Linux/ELFWindows/PE动态库文件.so.dll链接期接口头文件 .so里的动态符号表头文件 导入库(.lib)导出符号.dynsym 动态符号表.edata 导出表导入符号.dynsym GOT/PLT导入表 IAT调用外部函数方式通过PLT跳转到GOT通过导入表间接跳转地址无关方案PIC 全局偏移表基址重定位 IAT修正运行时加载APIdlopen / dlsym / dlcloseLoadLibrary / GetProcAddress / FreeLibrary运行库glibcMSVC CRTvcruntime等这张表列出来以后你会发现Windows其实是一套“直接填地址”的思路可执行文件里有一张导入表告诉加载器“我需要哪些DLL、需要哪些函数”加载器把DLL加载进来后直接把这些函数的真实地址写进IAT程序运行时从IAT里读出地址跳过去就行。Linux的GOT/PLT虽然也能实现类似效果但设计上更强调“位置无关”所以多了一层间接。理解这个差异后你自然就明白为什么Windows的DLL总是一堆“找不到模块”的问题——因为加载期就要把IAT填完任何一个导入项失败整个程序直接拒绝启动。2. DLL的导出、导入与两种链接方式这一章最硬核的部分2.1 导出符号__declspec(dllexport)与.def文件书里首先重点讲的是“导出”。一个DLL要给别人用总得把一些函数暴露出来。在Visual Studio里最常用的导出方式就是__declspec(dllexport)写在函数声明前面即可extern C __declspec(dllexport) int Add(int a, int b) { return a b; }这里有两个细节值得注意。第一extern C会关闭C的名字改编让导出符号保持Add而不是?AddYAHHHZ这种编译后的修饰名。C编译器对函数名进行改编name mangling是为了支持重载但到了DLL导出表里一堆修饰名会让别的语言比如C#、Python很难识别你的接口所以凡是准备跨语言、跨模块调用的导出函数一律用extern C包裹。第二如果你不想在代码里到处写__declspec(dllexport)也可以用模块定义文件.def来声明导出格式很简单LIBRARY MyMath EXPORTS Add.def文件的显式导出还能让你控制导出符号的名字比如把C修饰名映射成更友好的名字。书里对这块讲得很细我个人的建议是小项目用__declspec(dllexport)最省事但一旦涉及给外部系统提供稳定接口、或者接口数量很多用.def更可控因为它把“导出了什么”集中在一份文件里review 一眼就能看完。2.2 隐式链接与显式链接到底选哪个导出之后就是导入。Windows下调用DLL大体有两条路书里称为动态链接的两种方法。隐式链接implicit linking是用导入库.lib完成的。你在项目的“链接器→输入→附加依赖项”里加上这个.lib链接器就会在生成的.exe的导入表里记录“这个程序依赖哪个DLL的哪个函数”。程序启动时Windows加载器负责把这些依赖全部解析完毕然后把函数地址写进IAT你的代码里直接正常调用Add(1,2)就行。这个方式最大好处是调用方便、编译期类型检查还在但坏处也很明显只要这个DLL缺失、版本不对、架构不匹配程序在启动瞬间就挂了弹窗“0xc0000135 找不到XXX.dll”你连一点错误处理的机会都没有。显式链接explicit linking则是用LoadLibraryGetProcAddress在代码里手动加载DLL并取得函数指针#include windows.h typedef int (*AddFunc)(int, int); HMODULE hMod LoadLibrary(LMyMath.dll); if (!hMod) { // 这里可以给用户一个友好的提示而不是直接崩溃 return -1; } auto add reinterpret_castAddFunc(GetProcAddress(hMod, Add)); if (add) { int result add(1, 2); } FreeLibrary(hMod);显式链接虽然写起来啰嗦但它把“DLL不存在”这类问题从“进程启动阶段”推迟到了“代码执行阶段”你终于有机会做降级处理。比如你的程序里有一个可选功能依赖某个第三方DLL用显式链接加载DLL不在时你就隐藏这个功能如果当初用隐式链接这功能就直接把整个程序带崩了。书里对这两者的取舍讲得很明白我补充一句实践心得插件架构、SDK集成、功能可选型场景优先显式链接同一团队维护、版本可控的核心库用隐式链接提高开发效率即可。2.3 DLL搜索顺序那个让很多人调了半天的问题无论是隐式链接还是显式链接的LoadLibrary最终都要回答“去哪个目录找DLL”。Windows是有严格搜索顺序的书里给了权威顺序应用程序所在目录 → 系统目录比如 System32→ Windows目录 → 当前工作目录 → PATH 环境变量列出的目录。这条顺序规则最常见的坑有两个。第一个坑很多人喜欢把DLL放到System32里图省事。系统目录在搜索顺序里排第二仅次于应用程序目录看起来好像能解决“到处找不到DLL”的问题但System32里塞满各家的DLL之后“DLL地狱”就来了A版本覆盖了B版本谁先启动谁说了算。第二个坑程序依赖一个不在自己目录下的DLL你把DLL丢到某个自定义目录然后加了PATH本地跑通了换一台机器又失败因为不同机器的PATH配置不一样。更稳妥的做法是永远让DLL和应用放在同一个目录或者用相对路径自己控制加载位置。较新版本的Windows还支持LoadLibraryEx的LOAD_LIBRARY_SEARCH_*标志可以精确指定搜索范围我建议在对外发布的正式产品里用这个API能从根本上防止DLL搜索顺序被劫持。3. 绕不开的运行库Visual C Redistributable与CRT的恩怨3.1 /MD、/MT、/LD一个看似简单却很要命的配置第9章后面有一部分内容是运行时库与DLL的关系。Windows上的C/C程序几乎都离不开微软的C运行时库CRT就是那堆msvcp140.dll、vcruntime140.dll、ucrtbase.dll。你在Visual Studio里总能看到“运行库”配置它决定了CRT怎么链接进你的程序。四选一多线程DLL/MD、多线程静态/MT、多线程DLL调试/MDd、多线程静态调试/MTd而生成DLL的工程还会有 /LD 等选项。很多新手完全没意识到这个选项的严重后果。如果整个项目都选 /MTCRT会被静态链接进exe部署时不需要装Redistributableexe体积大一点但很省心如果某个模块选了 /MD运行时就要求目标机器装了对应版本的Visual C Redistributable否则就会弹出“找不到VCRUNTIME140.dll”。问题更隐蔽的在于混合使用项目里一个静态库用 /MT 编译主程序用 /MD 编译双方各自有一套CRT虽然短期内不报错但只要发生跨模块分配内存、跨模块释放或者STL容器跨模块传递就可能出现诡异的崩溃——因为它们在各自不同的堆上管理内存。书里对此有系统阐述我读到这里立刻想到自己踩过的一个坑一个插件DLL用 /MD主程序用 /MT插件里new出来的字符串传到主程序里用delete十个案例八个崩。后来定规矩全项目统一运行库选项谁都不许单独改。3.2 Redistributable 到底是什么热搜词里“microsoft visual c 2015-2022 redistributable (x64) 下载”出现频率极高这恰好是CRT在普通用户世界的名字。简单说Redistributable就是一个可再发行安装包里面打包了MSVC编译的程序运行所需的动态CRT和C标准库组件。因为微软把CRT拆成了动态库所以你的exe/DLL在别的机器上跑之前那台机器必须先装对应版本的运行库。现代Windows 11自带一部分但2015到2022这些新版本通常要自己装。这里有个很容易搞错的点Redistributable的x64/x86版本必须跟你的程序架构匹配。64位程序要装x64版运行库32位程序要装x86版。如果真的遇到“应用程序无法正常启动 0xc000007b”十有八九就是架构不匹配程序是64位却加载了32位版本的某个DLL或者反过来。我之前帮一个同事排查他把32位运行库装得齐齐的程序一跑就0xc000007b最后发现程序是x64的装个x64的Redistributable立刻解决。所以在菜单中选择“下载 x64 还是 x86”之前先确认你的exe架构。3.3 实际项目中运行时库与部署的经验读完这一章再回头看实际项目很多之前靠“瞎试”解决的部署问题突然有了理论支撑。我的打包流程现在基本固定优先看工程配置里所有模块的运行库选项统一为 /MD确定无人用 /MT然后把对应的Visual C Redistributable安装包放进部署脚本最后用Dependencies工具Dependency Walker的现代替代品打开exe检查所有依赖的DLL是否都来自受控目录。静态链接虽然省事但遇到安全更新时所有用静态CRT的程序都要重新编译一遍才能修复漏洞代价并不小。这也是很多公司宁可带Redistributable也不选择全 /MT 的原因之一。4. 把第9章用到真实项目从构建DLL到第三方库集成4.1 用Visual Studio构建并导出一个DLL书里的原理再多不亲手拉一个项目还是容易忘。我当时边读边跟着做了一个最小的DLL工程新建VS动态链接库项目把导出的Add函数写好然后在同一个解决方案里建一个exe工程引用它。整个过程中最值得记的是两个细节。头文件里要做__declspec(dllexport)和__declspec(dllimport)的切换给DLL自身编译时是导出给别人用时是导入。常见做法是定义一个宏#ifdef MYMATH_EXPORTS #define MYMATH_API __declspec(dllexport) #else #define MYMATH_API __declspec(dllimport) #endif extern C MYMATH_API int Add(int a, int b);MYMATH_EXPORTS由DLL项目里的预处理器定义exe项目则不定义这样同一份头文件两边通用这正是书里讲解的“导入导出表只是符号层面的约定最终由编译器在目标文件里写死”。另一个细节是链接错误。新建的exe项目直接调用Add时VS会报 LNK2019 未解析的外部符号。这很正常因为还没告诉链接器去哪个导入库里找。你需要在exe项目的“链接器→输入→附加依赖项”里加上MyMath.lib并确保“附加库目录”指向DLL项目生成的目录。很多人第一次卡在这里其实不是代码问题而是链接层面没配上。想通“exe编译期只需要头文件和.lib运行时才需要.dll”这套关系后这类问题就再也不会困住你了。4.2 动态加载真实第三方库TDengine的C绑定写入光写自己的DLL还不够我更建议你找一两个真实的第三方动态库实验一下。我最近一个项目里用到了TDengine时序数据库的C/C客户端库因为它恰好就是一套典型的动态库SDK头文件 导入库 运行时DLL链接方式和前面讲的如出一辙。TDengine在Windows下提供了taos.dll的Release版本在链接时则需要配对taos.lib调用的接口里包含taos_stmt_prepare这类参数绑定函数用来将C代码里的变量安全地绑定进SQL语句避免手工拼接字符串导致注入或格式错误。大致流程是这样#include taos.h TAOS *taos taos_connect(127.0.0.1, root, taosdata, test, 0); TAOS_STMT *stmt taos_stmt_init(taos); const char* sql INSERT INTO ? USING meters TAGS(?) VALUES (?, ?); taos_stmt_prepare(stmt, sql, 0); // 绑定表名、标签、时间戳、数值等参数 struct TAOS_BIND bind[4]; memset(bind, 0, sizeof(bind)); // ... 填充每个 bind 的长度、类型、缓冲区 taos_stmt_bind_param(stmt, bind); taos_stmt_execute(stmt); taos_stmt_close(stmt); taos_close(taos);这个例子从动态链接的角度看有几个值得说的点。首先taos_stmt_prepare这类API能正常工作前提是程序运行时可找到taos.dll否则你会在启动阶段得到“找不到指定的模块”弹窗所以一定要把DLL放在exe同目录或者显式调用LoadLibrary加载它。其次如果你的程序是64位那么taos.dll必须是64位版本32位和64位混装就会出现0xc000007b。最后如果你用CMake构建记得在target_link_libraries里加上导入库并在COPY阶段把运行时DLL带回输出目录这一步看似不起眼但在CI机器上经常因为漏掉而翻车。我当时就是边读第9章边集成TDengine等于书里的每个概念都对应到了一个真实报错效率特别高。4.3 构建配置与链接错误排查套路我读这一章前后被两类问题反复折磨正好一并写出来。第一类是“64位fopen报安全错误”。在MSVC下直接调用fopen会得到C4996警告或错误提示你改用fopen_s。这其实不是“64位”特有的问题而是MSVC把一批C函数标记为“不安全”强制推荐带_s后缀或加参数检查的版本。如果项目里有大量旧代码不想改可以在预处理器里加上_CRT_SECURE_NO_WARNINGS来屏蔽但最好还是按编译器提示改掉。第二类是大项目构建时常见的链接错误混战。最常见的是“LNK2019无法解析的外部符号”和“LNK2001无法解析的外部符号已定义”。看到这类错误先别急着一头扎进代码按顺序排查确认是否加入了对应导入库确认函数名是否因为C/C冲突发生名字改编比如缺了extern C确认调用方和被调方的架构一致x86/x64不能混确认库文件和项目选用的CRT运行库一致。把这几条挨个过一遍绝大多数链接错误都能定位。书里虽然不会教你具体报错长什么样但只要理解了“链接期找符号、运行期找模块”这条分界线排查思路自然就有了。5. 开发环境里的联动问题从VSCode到八股文5.1 VSCode配置C/C环境时最容易忽略的东西这几年越来越多的人用VSCode写C热搜词里“vscode配置c/c环境”“vscode c所有的函数 变量 都没办法跳转”出现频率很高正好和这一章的阅读体验连在一起。VSCode的C/C插件本质上依赖两件东西编译器和IntelliSense配置。很多人的问题是“头文件明明存在但函数跳转就是失败、报警一堆红波浪线”原因多半是c_cpp_properties.json里的compilerPath和includePath没配好。includePath要指向C标准库头文件所在目录以及项目自身的头文件根目录。如果你在Windows上用的MSVC还得让compilerPath指向cl.exe并且通过VS的“开发人员命令提示符”启动VSCode来继承环境变量。如果函数跳转还是失效可以装上“C/C Compile Run”或生成compile_commands.json交给插件使用。这个排查过程虽然和DLL没有直接关系但底层逻辑是一样的IDE需要在“编译期层面”看到完整的符号信息才能给你提供可靠的跳转和补全跟程序运行时找DLL符号本质上是同一种依赖关系。5.2 C八股文与底层功底的平衡最近网上聊“C八股文”聊得很凶热搜里也有大量算法、STL和经典面试题比如快速幂、单调栈、判断质数优化、字符串数组初始化、结构体链表基本语法这些。我不是说刷这些没用事实上准备GESP认证这类考试时算法和语法确实是大头。但读完《程序员的自我修养》第9章后我强烈感觉到一个只会刷题、不懂编译链接和动态库的C开发者和真正能落地的工程师之间存在一条明显的分界线。你可以在答题时熟练默写快速幂算法、把STL容器到底用哪种底层细节背得滚瓜烂熟但一旦项目里出现“为什么我的DLL导出函数在C#里调用不了”、“为什么同一个符号在Debug和Release下修改名不一样”、“为什么链接时报重复符号”没有底层功底的会直接卡死。反过来如果你理解了导出导入表、名字改编、运行库这些机制你反而能更深刻地理解为什么C的“八股”里会有那么多关于内存、对象生命周期、编译期行为的题。代码不只是写给人看的更是编译成机器码、链接进模块、被操作系统装载起来运行的这一整套流程才是C程序员“自我修养”的真正含义。这一章值得每隔半年重读一遍配合实际项目里的DLL、lib、Redistributable问题反复咀嚼。6. 写在最后从“知道Linux动态链接”到“看懂Windows DLL”如果让我概括第9章带给我的变化那就是我终于把Windows下动态链接的“体验派”上升到了“理解派”。以前遇到DLL的问题我的解法基本是百度关键词“找不到VCRUNTIME140.dll”“0xc000007b”然后照着别人的帖子装运行库、改环境变量、把DLL复制进System32运气好解决了运气不好一上午就这么耗掉。读完这一章之后同样的报错在我眼里变成了一个结构化的排查过程先判断是加载期失败还是链接期失败再判断是依赖缺失还是架构不匹配最后根据搜索顺序和CRT配置定位根因。我在实际项目里最明显的改变是每次交付给部署的同学我都会附带一张“运行依赖清单”写明需要安装哪个版本的Visual C Redistributable、有哪些DLL必须和exe放在一起、x64还是x86。这件事看起来很小但在现场调试时能省下大把时间。最后再分享一个值得一试的实验下载一个你电脑上常见的软件exe用Dependencies工具打开看看它的导入表里到底依赖了哪些DLL再试着把一个同名DLL换成另一个版本观察程序是否报错。这个简单的实验会让你对“动态链接”四个字的理解产生质的飞跃比单纯读书刻骨铭心得我敢保证。
返回列表