C语言宏展开机制解析:从预处理到递归重扫描的完整指南
在实际 C 语言开发中无论是阅读开源项目代码还是编写自己的跨平台模块都绕不开预处理指令和宏。很多初学者对#include、#define的理解停留在“文件包含”和“文本替换”的层面一旦遇到带参数的宏、条件编译、宏嵌套或者#、##操作符就很容易产生困惑甚至写出难以调试的代码。更棘手的是宏展开过程中的递归、重扫描等机制如果理解不清会导致编译错误或者产生与预期完全不同的代码。本文将从预处理器的视角完整解析一个 C 源文件从文本到编译单元的形成过程并重点拆解宏展开的核心流程。你会理解为什么#define SQUARE(x) x*x在SQUARE(a1)时会产生错误结果以及如何利用#和##编写更灵活的代码。本文适合有一定 C 语言基础希望深入理解编译过程、避免宏陷阱或需要编写复杂宏定义的开发者。1. 预处理阶段编译器眼中的第一步在 C 语言的标准编译流程中预处理是独立且最先发生的阶段。它的输入是.c源文件输出是一个“翻译单元”这个单元才是后续词法分析、语法分析等真正编译阶段处理的对象。预处理器的核心工作可以概括为以下几项并且严格按照此顺序执行字符映射与三字符组替换将源文件的物理字符如物理行结束符映射为逻辑行并处理古老的“三字符组”如??替换为#。现代编码中这一步影响很小。行拼接如果一行以反斜杠\结束则将其与下一行物理行拼接成一个逻辑行。令牌化将逻辑行分解为预处理令牌如标识符、常量、字符串字面量、操作符等和空白字符。注释在此阶段被替换为单个空格。预处理指令执行与宏展开这是核心。预处理器查找以#开头的行执行#include,#define,#ifdef,#line等指令。其中#define定义的宏会在后续的令牌序列中被展开替换。空白字符处理与字符串字面量连接相邻的字符串字面量如Hello World会被连接成一个。对于开发者而言最需要关注的就是第 4 步宏的展开流程。这个流程并非简单的“查找并替换”而是一套有严格规则的递归重扫描机制。1.1 宏定义的基本形式与对象宏宏定义通过#define指令实现主要分为两种对象宏和函数宏。对象宏无参宏是最简单的形式它为一个令牌序列定义一个别名。#define PI 3.1415926 #define BUFFER_SIZE 1024 #define HELLO_MSG Hello, World!\n预处理器在处理后续代码时会将所有独立出现的PI、BUFFER_SIZE、HELLO_MSG替换为它们对应的令牌序列。这里的“独立出现”指该标识符不是另一个更大标识符的一部分也不是字符串字面量或注释中的内容。1.2 函数宏带参宏函数宏可以接受参数其行为类似于函数但本质仍是文本替换。#define MAX(a, b) ((a) (b) ? (a) : (b)) #define SQUARE(x) ((x) * (x))函数宏的调用形式为MAX(10, 20)预处理器会用实参10和20去替换宏定义体((a) (b) ? (a) : (b))中的形参a和b。这里括号的使用至关重要我们会在后续展开流程中看到原因。2. 宏展开的核心算法递归重扫描宏展开的核心是一个基于“蓝帧”的递归重扫描算法。理解这个算法是避免宏相关错误的关键。其核心规则如下禁用递归展开当一个宏正在展开时它自身即同一个宏名在本次展开过程中被禁用防止无限递归。例如#define A A展开A时遇到A自身则不再展开防止死循环。参数先展开在函数宏展开时实参在被替换到宏体之前会先进行完整的宏展开除非该实参是#或##的操作数。展开后的结果再替换进宏体。宏体重扫描将实参替换进宏体后生成一个新的令牌序列。预处理器会从头开始重新扫描这个新序列以展开其中可能包含的其他宏。上下文依赖在重扫描过程中规则1依然有效。本次展开所禁用的宏在重扫描时依然保持禁用状态。让我们通过一个经典例子来可视化这个过程#define CONCAT(a, b) a ## b #define CONCAT_INDIRECT(a, b) CONCAT(a, b) #define XYZ 10 int value CONCAT_INDIRECT(X, YZ);问最终value被初始化为什么展开步骤分析预处理器遇到CONCAT_INDIRECT(X, YZ)。根据规则2先展开实参。实参X和YZ都是独立的标识符。X未被定义过宏保持不变。YZ呢这里有一个关键点YZ是一个完整的标识符它并不等于Y和Z的拼接。当前环境中没有名为YZ的宏定义所以YZ也保持不变。实参展开结果为X和YZ。将它们替换到CONCAT_INDIRECT的宏体CONCAT(a, b)中得到新的令牌序列CONCAT(X, YZ)。开始重扫描CONCAT(X, YZ)。遇到CONCAT(X, YZ)这是一个宏调用。先展开其实参X和YZ同样它们没有其他宏定义保持不变。将X和YZ替换到CONCAT的宏体a ## b中。##是“令牌粘贴”操作符它将两边的令牌X和YZ连接成一个新的令牌XYZ。重扫描结果令牌XYZ。XYZ是一个宏它被定义为10。因此XYZ被展开为10。最终int value 10;。这个例子展示了参数先展开、重扫描以及##操作符的生效时机。如果我们将调用改为CONCAT(XY, Z)过程会完全不同最终可能无法展开因为XY和Z粘贴后是XYZ但此时XYZ作为粘贴结果在本次重扫描中是否会被再次展开取决于标准的具体实现细节通常为了安全粘贴产生的新令牌在本次扫描中不会再次展开。这引出了另一个重要概念蓝帧。2.1 蓝帧管理禁用宏的栈预处理器内部使用一个“蓝帧”栈来跟踪在每一层展开中被禁用的宏。规则可以简化为开始展开一个宏M时将M推入当前蓝帧标记为禁用。在展开M的过程中包括参数展开和宏体重扫描如果遇到M则跳过不展开。当M的展开完全结束后将M从蓝帧中弹出。这有效防止了#define A A这类直接递归。对于间接递归如#define A B/#define B A在展开A时A被禁用展开为B重扫描B时B被展开为A但此时A仍在蓝帧中处于禁用状态因此展开停止结果就是A。这通常会导致编译错误或意想不到的标识符残留。3. 操作符#和##的特殊规则#字符串化和##令牌粘贴是仅在宏定义中有效的预处理操作符。3.1 字符串化操作符##将其后的宏参数转换为一个字符串字面量。关键规则是该参数不会被展开。#define STRINGIFY(x) #x #define NUM 100 printf(%s\n, STRINGIFY(NUM)); // 输出什么展开流程调用STRINGIFY(NUM)参数是NUM。因为NUM是#的操作数所以它不被展开直接作为令牌NUM被字符串化。替换进宏体得到NUM。重扫描NUM是字符串字面量不再变化。最终代码printf(%s\n, NUM);输出字符串NUM而不是100。如果需要先展开参数再字符串化需要用到间接层#define STRINGIFY(x) #x #define EXPAND_AND_STRINGIFY(x) STRINGIFY(x) #define NUM 100 printf(%s\n, EXPAND_AND_STRINGIFY(NUM)); // 输出什么展开流程调用EXPAND_AND_STRINGIFY(NUM)参数是NUM。参数NUM先被展开因为它不是#或##的直接操作数得到100。将100替换进宏体STRINGIFY(x)得到STRINGIFY(100)。重扫描遇到STRINGIFY(100)参数100是#的操作数不被展开直接字符串化为100。最终代码printf(%s\n, 100);输出字符串100。3.2 令牌粘贴操作符####将其左右两边的令牌连接成一个新的令牌。关键规则是##两边的参数如果本身是宏会先被展开然后再进行粘贴。但是粘贴形成的新令牌在本次重扫描中不会被再次展开。#define PASTE(a, b) a ## b #define CLASS_NAME(name) PASTE(MyClass_, name) #define VERSION _v1 CLASS_NAME(Widget); // 期望得到 MyClass_Widget CLASS_NAME(VERSION); // 期望得到 MyClass__v1对于CLASS_NAME(Widget):展开CLASS_NAME(Widget)-PASTE(MyClass_, Widget)。展开PASTE的实参MyClass_(非宏)Widget(非宏)。粘贴得到MyClass_Widget。这是一个新令牌本次扫描中不再展开。结果正确。对于CLASS_NAME(VERSION):展开CLASS_NAME(VERSION)-PASTE(MyClass_, VERSION)。展开PASTE的实参MyClass_(非宏)VERSION(是宏展开为_v1)。粘贴得到MyClass__v1。新令牌不再展开。结果正确。如果MyClass_或最终结果恰好也被定义成了宏由于“新令牌不展开”的规则它们也不会被错误地二次展开这通常是我们期望的行为。4. 宏展开中的常见陷阱与最佳实践理解了展开规则就能解释和避免许多常见错误。4.1 陷阱1参数求值副作用与多次展开#define MAX(a, b) ((a) (b) ? (a) : (b)) int x 1, y 2; int z MAX(x, y); // 展开后是什么展开结果((x) (y) ? (x) : (y))问题如果x y为真则x会被递增两次。这完全不同于函数调用函数参数只求值一次。这是宏的一个重大缺陷。最佳实践对于可能产生副作用的参数如i,func()避免使用宏。使用内联函数inline是更安全的选择。如果必须用宏确保参数在宏体中只出现一次或者使用者明确知晓该风险。4.2 陷阱2运算符优先级问题#define SQUARE(x) x * x int result SQUARE(1 2); // 期望 9实际得到展开结果1 2 * 1 2根据运算符优先级计算结果为5而非期望的9。最佳实践始终用括号包裹整个宏体#define SQUARE(x) ((x) * (x))始终用括号包裹每个参数在宏体中每个出现参数的地方都加上括号。如上例中的(x)。4.3 陷阱3分号吞噬#define LOG(msg) printf(Log: %s\n, msg); if (condition) LOG(Something happened); else do_something_else();展开后if (condition) printf(Log: %s\n, msg);; else // 这里多了一个分号导致语法错误 do_something_else();最佳实践定义多语句宏时使用do { ... } while(0)结构包裹。#define LOG(msg) do { \ printf(Log: %s\n, (msg)); \ fflush(stdout); \ } while(0)这样调用后跟一个分号是安全的并且是一个独立的语句块。4.4 陷阱4宏名与上下文冲突#define MAX_SIZE 256 int buffer[MAX_SIZE]; // 正确 struct MAX_SIZE { ... }; // 错误宏替换了结构体标签名最佳实践使用全大写、带前缀或下划线的宏名以减少冲突概率如PROJECT_NAME_MAX_SIZE。在可能冲突的局部区域可以使用#undef取消宏定义但需谨慎。5. 条件编译中的宏展开#if、#elif后面的表达式在求值前其中的宏也会被展开。但#ifdef、#ifndef只检查标识符是否被定义不进行展开。#define DEBUG_LEVEL 2 #define FEATURE_ENABLED 0 #if DEBUG_LEVEL 1 FEATURE_ENABLED // 这段代码会被编译吗 #endif预处理器会先将DEBUG_LEVEL展开为2FEATURE_ENABLED展开为0然后计算表达式2 1 0结果为0假因此其中的代码不会被编译。注意#if要求展开后的结果是一个有效的整型常量表达式。如果DEBUG_LEVEL被定义为字符串或其它类型会导致编译错误。6. 调试宏展开查看预处理结果理解理论后如何验证宏的展开结果大多数编译器都提供了只进行预处理的选项。GCC/Clang: 使用-E选项。gcc -E source_file.c -o preprocessed_output.i生成的.i文件就是经过预处理后的翻译单元所有宏都已被展开注释已移除头文件内容已被插入。MSVC: 使用/E或/EP选项。cl /E source_file.c preprocessed_output.i查看这个文件是学习宏展开、诊断宏相关错误的最直接方法。你会看到#line指令和展开后的庞大代码需要耐心定位你关心的部分。7. 宏 vs. 内联函数如何选择C99 引入了inline关键字为很多原本必须使用宏的场景提供了更安全的替代方案。特性宏 (#define)内联函数 (inline)本质文本替换真正的函数有类型检查类型安全无。任何类型都能传入错误可能延迟到编译或运行时。有。编译器检查参数和返回类型。副作用参数可能被多次求值导致副作用放大。参数按函数调用规则只求值一次。调试难以调试调试器看到的是展开后的代码。可以像普通函数一样调试尽管可能被内联。适用场景生成代码片段、字符串化 (#)、令牌粘贴 (##)、条件编译选择不同类型代码、需要“泛型”操作如容器。替代简单的函数式宏进行性能优化需要类型安全和可调试性。作用域从定义点到文件尾或#undef无视代码块。遵循 C 语言的作用域规则。决策建议如果操作涉及#、##或需要根据编译条件生成不同的代码结构必须使用宏。如果只是进行简单的计算或判断并且参数可能有副作用优先使用static inline函数。在头文件中定义公共的、小型且频繁调用的函数可以考虑使用inline或宏但要注意宏的副作用和命名污染问题。8. 一个综合案例实现简单的日志宏结合所学我们实现一个功能更完善的日志宏它支持日志级别并能自动输出文件名和行号。// log_macro.h #ifndef LOG_MACRO_H #define LOG_MACRO_H #include stdio.h // 定义日志级别 #define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_ERROR 3 // 设置当前编译日志级别 #ifndef CURRENT_LOG_LEVEL #define CURRENT_LOG_LEVEL LOG_LEVEL_DEBUG #endif // 辅助宏将日志级别转换为字符串 #define _LOG_LEVEL_STRING(level) #level #define LOG_LEVEL_STRING(level) _LOG_LEVEL_STRING(level) // 核心日志宏 #define LOG(level, fmt, ...) do { \ if ((level) CURRENT_LOG_LEVEL) { \ const char* level_str; \ switch(level) { \ case LOG_LEVEL_DEBUG: level_str DEBUG; break; \ case LOG_LEVEL_INFO: level_str INFO; break; \ case LOG_LEVEL_WARN: level_str WARN; break; \ case LOG_LEVEL_ERROR: level_str ERROR; break; \ default: level_str UNKNOWN; \ } \ fprintf(stderr, [%s] %s:%d: fmt \n, \ level_str, __FILE__, __LINE__, ##__VA_ARGS__); \ } \ } while(0) // 便捷宏 #define LOG_DEBUG(fmt, ...) LOG(LOG_LEVEL_DEBUG, fmt, ##__VA_ARGS__) #define LOG_INFO(fmt, ...) LOG(LOG_LEVEL_INFO, fmt, ##__VA_ARGS__) #define LOG_WARN(fmt, ...) LOG(LOG_LEVEL_WARN, fmt, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) LOG(LOG_LEVEL_ERROR, fmt, ##__VA_ARGS__) #endif // LOG_MACRO_H使用示例// main.c #define CURRENT_LOG_LEVEL LOG_LEVEL_INFO // 只输出 INFO 及以上级别 #include log_macro.h int main() { int x 10; LOG_DEBUG(Debug message, x%d, x); // 这行不会输出因为级别低于 CURRENT_LOG_LEVEL LOG_INFO(Program started.); LOG_WARN(Value of x is large: %d, x); LOG_ERROR(An error occurred!); return 0; }这个宏的实现运用了多个知识点条件编译通过CURRENT_LOG_LEVEL控制输出。do { ... } while(0)包裹多语句安全使用分号。可变参数宏...和__VA_ARGS__支持格式化字符串。##__VA_ARGS__中的##是 GNU 扩展在 MSVC 中也常见用于处理可变参数为空的情况避免编译错误。预定义标识符__FILE__和__LINE__在预处理时被替换为当前文件名和行号。间接字符串化LOG_LEVEL_STRING宏可以先将参数展开再转换为字符串。理解宏展开流程不仅能帮你写出正确的宏更能让你在遇到复杂的、嵌套的宏定义时有能力一步步推导出最终代码的样子从而高效地调试和解决问题。在阅读 Linux 内核、开源库等大量使用宏的代码时这项技能尤为重要。

相关新闻

Nacos启动闪退与Spring Cloud Alibaba版本兼容性:一站式解决方案

Nacos启动闪退与Spring Cloud Alibaba版本兼容性:一站式解决方案

1. 从一次典型的本地开发环境崩溃说起那天下午,我正准备调试一个微服务模块,像往常一样双击了 Nacos 的startup.cmd脚本。熟悉的黑色窗口一闪而过,然后……什么都没发生。Nacos 服务根本没起来。紧接着,当我尝试在 IDEA 里启动我的…

2026/10/14 5:33:51 阅读更多 →
C++/Qt QMap遍历全解析:从迭代器到范围for循环的性能与安全实践

C++/Qt QMap遍历全解析:从迭代器到范围for循环的性能与安全实践

1. 项目概述:为什么我们需要关注QMap的遍历?在C/Qt开发中,QMap是一个我们再熟悉不过的容器了。它基于红黑树实现,提供了键值对的自动排序和高效的查找能力。从管理配置项、缓存数据到构建小型的内存数据库,QMap的身影无…

2026/10/14 9:53:27 阅读更多 →
Scratch进阶:变量与运动模块的极简结合,实现动态游戏逻辑

Scratch进阶:变量与运动模块的极简结合,实现动态游戏逻辑

很多Scratch初学者,包括一些已经能做出简单动画的孩子,在尝试创作更复杂的互动游戏时,常常会遇到一个瓶颈:角色要么只会机械地重复几个动作,要么运动逻辑混乱,无法实现想象中的效果。比如,想让一…

2026/10/14 21:16:25 阅读更多 →

最新新闻

PyTorch同维度张量matmul:规则、广播与性能优化实战

PyTorch同维度张量matmul:规则、广播与性能优化实战

1. 为什么同维度张量乘法值得单独拎出来说刚接触 PyTorch 的人,十个里有八个会在torch.matmul上栽跟头。原因很简单:torch.mul、torch.mm、torch.matmul、运算符、torch.bmm这几个东西名字长得像,功能又互相重叠,文档里一句话带过…

2026/10/15 22:11:42 阅读更多 →
Python Socket远程截屏实战:TCP通信与图像传输原理详解

Python Socket远程截屏实战:TCP通信与图像传输原理详解

1. 从零理解Socket远程截屏的技术本质1.1 这个项目到底在做什么先把话说清楚:这个项目的核心目标,是用Python的socket模块搭建一条最基础的C/S(客户端/服务端)通信链路,让服务端能够向客户端发送指令,客户端…

2026/10/15 22:11:42 阅读更多 →
PostgreSQL常用SQL实战合集:从库表管理到性能调优

PostgreSQL常用SQL实战合集:从库表管理到性能调优

1. 为什么需要一套能直接用的PostgreSQL SQL合集做后端开发、数据分析和运维的人,几乎都躲不开PostgreSQL。相比MySQL,PostgreSQL在数据类型、窗口函数、JSON支持、索引类型上的能力强出不少,但也正是因为功能多,日常写SQL时反而容…

2026/10/15 22:11:42 阅读更多 →
桌面文件太乱?使用华为云码道开发Python文件自动分类工具

桌面文件太乱?使用华为云码道开发Python文件自动分类工具

华为云码道CodeArts代码智能体_下载安装_免费体验-华为云 Security Verification 深圳技术大学 - 开源代码托管,代码协作 - AtomGit Python文件自动分类整理工具:基于 Python 标准库开发的本地文件自动整理工具,可按照文件扩展名将文件分类至图片、文档、视频、音…

2026/10/15 22:11:42 阅读更多 →
通配符匹配利器:深入解析 Harvester 依赖的 Go 模式匹配库 tidwall/match

通配符匹配利器:深入解析 Harvester 依赖的 Go 模式匹配库 tidwall/match

云原生虚拟化存储后端高可用 【免费下载链接】harvester Open source hyperconverged infrastructure (HCI) software 项目地址: https://gitcode.com/gh_mirrors/ha/harvester 点击查看 免费下载 导读 tidwall/match 是一个极简的 Go 通配符(wildcard…

2026/10/15 22:11:42 阅读更多 →
基于React Native鸿蒙的智能调度分配模块设计与实践

基于React Native鸿蒙的智能调度分配模块设计与实践

做服务调度类项目,最让人头大的从来不是页面能做得有多花哨,而是“这件事到底该派给谁、怎么快速判断那个人现在真能用”这一连串业务规则背后的逻辑。最近我负责了一个基于React Native鸿蒙跨平台实现的调度分配模块,核心需求很明确&#xf…

2026/10/15 22:10:40 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/15 9:49:14 阅读更多 →