news 2026/10/3 7:42:17

C语言预处理指令完全指南:从宏展开到条件编译的坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言预处理指令完全指南:从宏展开到条件编译的坑

写C语言的人,早晚都会跟预处理指令正面相遇。平时代码里写#include、#define像是喝水吃饭一样自然,可真到排查 bug 的时候,往往是这些“看不见的文本替换”在背后搞事情。我见过很多新手甚至工作几年的开发者,对着一个诡异的编译错误或者运行期异常百思不得其解,最后发现是宏展开的顺序、头文件重复包含、条件编译分支没走对这类预处理层面的问题。这篇文章我把 C 语言预处理指令的核心机制、常用指令的底层逻辑、以及实战中踩过的坑一次性梳理清楚,适合刚入门 C 语言的同学建立完整认知,也适合写了几年 C 但一直靠“经验主义”写宏的老手查漏补缺。

1. 预处理阶段到底在哪一步,为什么它决定了一半的 bug

很多教材一上来就讲#define怎么用、#include怎么写,却很少讲清楚:预处理指令到底是什么时候执行的、由谁执行、执行完以后代码变成了什么样子。结果就是你会用宏,但不知道宏替换后发生了什么,只能靠背结论来规避问题。我建议先把预处理在编译流程中的位置搞明白,很多困惑会迎刃而解。

1.1 从源码到可执行文件,预处理职能的精确切割

一个.c文件要变成可执行文件,路径大致是:预处理(preprocessing)→ 编译(compilation)→ 汇编(assembly)→ 链接(linking)。你写的预处理指令,在处理器的“预处理阶段”完成,这个阶段在真正的语法、语义分析之前。换句话说,编译器拿到手的是预处理完之后的翻译单元,不是你手写的那份源码。

你可以用下面这条命令直观感受一下预处理器的产出:

gcc -E demo.c -o demo.i

demo.i是把#include的头文件内容原样展开、把所有宏做文本替换、把注释删掉之后的纯文本结果。我第一次打开.i文件的时候挺震撼的,一个几十行的源文件展开后能变成上千行,原因就是stdio.h这些标准库头文件的内容被完整拼进来了。所以预处理器本质上是一个“文本加工厂”,它负责的活包括:

  • 把#include指定的文件内容插入到指令位置
  • 把#define定义的宏在后续代码中逐字展开
  • 处理#if、#ifdef、#ifndef等条件编译分支,删除不满足条件的分支
  • 删除源码中的注释
  • 给每一行添加行号标记,方便编译器报错时定位到源文件位置

理解了这个“文本加工”属性,才能理解宏的很多怪癖。它不是编程语言层面的函数,它就是个无脑替换器。我常用一个切菜的比喻:预处理器是厨房里负责切配的帮工,你给他一个“土豆去皮切块”的指令(宏),他就机械地对每颗土豆执行,他不会思考这颗土豆是不是已经烂了,也不在乎你后面炒菜时想用整颗土豆。他只会严格按操作步骤执行。

1.2 一个最直观的展开示例:预处理器是“无脑替换者”

看这段代码:

#define SQUARE(x) x * x int main() { int a = 2; int result = SQUARE(a + 1); printf("%d\n", result); return 0; }

如果你以为输出是 9(3的平方),那你就踩进了预处理器挖的坑。预处理展开后,这行变成:

int result = a + 1 * a + 1;

由于 C 语言乘法优先级高于加法,实际计算是a + (1 * a) + 1,也就是2 + 2 + 1 = 5。预处理器根本不知道你想算“a+1 的和再平方”,它就是简单地把x替换成a + 1,完全不做语法层面、运算优先级层面的理解。

这就是理解预处理指令的一组基础认知:所有替换都发生在编译器真正解析代码之前,所以宏内部的格式、运算符、嵌套关系都必须靠写宏的人手动保护好。上面的SQUARE只要给参数加上括号也能修好:

#define SQUARE(x) ((x) * (x))

但加了括号只是解决优先级问题,还有别的坑在后面等着。预处理器的这种“无脑”在特定场景下反而是优点,不过你得先把它的行为模式摸透,否则就是拿着一把好刀乱挥。

2.#include的学问:不止是“引入头文件”这么简单

#include是 C 程序员最先接触的预处理指令,大多数人只会机械地写#include <stdio.h>,真被问到双引号和尖括号的区别、重复包含的后果,反而说不出个所以然。这里面的细节,直接关系到你的工程能不能在多文件下稳定编译。

2.1 尖括号与双引号背后是两条不同的查找路线

#include <stdio.h>和#include "myheader.h"的区别,本质是预处理器按什么路径去找文件。尖括号形式会到“系统头文件目录”里找,这是编译器预先配置好的路径,标准库头文件都待在这些位置。双引号形式则优先在“当前源文件所在目录”下找,找不到再去系统目录里找。我早些年为了图省事,项目里自己写的头文件也用尖括号#include <utils.h>,结果一到别的机器上编译就报找不到头文件,就是因为那台机器的系统头文件目录里根本没有这个文件。

正确的做法很纯粹:系统库和第三方库的头文件用尖括号,自己工程内部的头文件用双引号。这个约定不是风格问题,是工程可移植性的底线。另外,如果你用 GCC/Clang 编译,还可以通过-I参数给预处理器增加额外的查找目录:

gcc -c main.c -I./include -I../common

这会让预处理器在搜索系统目录之前先去这些指定目录里找头文件。我做嵌入式开发时,经常用这个方式把不同版本的驱动头文件目录切来切去,避免污染系统目录。

2.2 重复包含与 include guard:一场“宏变量”的博弈

假设你有三个文件:a.h、b.h、main.c,其中a.h定义了一个结构体,b.h里也写了#include "a.h",main.c既包含b.h又直接包含a.h。那么main.c经过预处理后,a.h的内容会完整地出现两次,结构体被定义两遍,编译器直接报重定义错误。

解决这个问题的标准方案就是在头文件里加上 include guard:

#ifndef A_H #define A_H typedef struct { int x; int y; } Point; #endif

第一次预处理遇到#ifndef A_H,因为A_H还没定义,所以进入分支、定义A_H、输出结构体定义。第二次再遇到时,A_H已经被定义了,整个头文件内容就被预处理器跳过了。这就是 include guard 的机制:不是“编译器记忆了什么”,而是预处理器记住了宏是否被定义。

顺手说一下#pragma once。很多现代编译器支持在头文件第一行写#pragma once,作用和 include guard 等价,而且更简洁。但它不是 C 标准规定的指令,极少数编译器可能不支持,或者在某些复杂的符号链接、虚拟文件系统场景下偶发失效。我的个人习惯是:开源项目、跨平台项目一律用 standard include guard,自有工具链控制的内部小项目可以用#pragma once换取代码清爽。两种方案的目的都是在预处理阶段挡住重复内容,要知道它们跟“避免多次包含”是同一个问题。

3. 宏定义的核心机制与三个最容易踩的坑

#define是预处理指令里的主角。对象宏定义一个符号常量,函数宏定义一段类函数的文本替换逻辑。但正因为预处理器只会“无脑替换”,它给不给力、坑不坑人,完全取决于写宏的人是否掌握了它的脾气。下面这三个坑,我几乎每年都会在不同团队里见到。

3.1 对象宏和函数宏的展开规则

对象宏是最简单的形式:

#define MAX_BUFFER_SIZE 1024

预处理器在后续代码里遇到MAX_BUFFER_SIZE,就直接替换成1024。这里有个隐藏细节:宏展开发生在词法层面,不是字符串拼接。也就是说MAX_BUFFER_SIZE这个词会被替换,但如果变量名是MAX_BUFFER_SIZE_EXTRA,它不会去替换其中的一部分,因为这是一个完整的标识符,不是独立的宏名字。

函数宏则更像模板:

#define MIN(a, b) ((a) < (b) ? (a) : (b))

展开时,预处理器用实参去替换形参。例如MIN(x + 1, y)会变成((x + 1) < (y) ? (x + 1) : (y))。我特意写了两层括号,这是函数宏的标配写法,因为预处理器不解析运算符优先级,包裹括号是把正确的求值顺序交给编译器处理。缺一层括号,上面的SQUARE就是前车之鉴。

3.2 括号与副作用:两个看似不起眼的隐患

宏的括号问题是优先级翻车,副作用问题是重复求值翻车。比如:

#define ABS(x) ((x) < 0 ? -(x) : (x)) int i = 5; int result = ABS(i++);

预处理后变成((i++) < 0 ? -(i++) : (i++))。这里i被自增了三次,对于i=5的情况,条件判断后走冒号分支,又执行一次i++,实际返回值是 6,但i最后变成了 8。这不是你写宏的时候希望的结果,但在预处理阶段它就这么发生了。函数调用不会有这个问题,形参在调用时被求值一次,但宏是文本替换,每次出现形参都会被执行。

所以在设计宏的时候要时刻问自己:这个宏的参数如果是一个带副作用的表达式,会不会出问题?如果会,就不适合用宏,或者要让这个宏在文档里明确标注“参数不能有副作用”。我在实际代码审查里看到过不止一次因为MIN、ABS这类宏传入自增表达式而导致的诡异 bug,最后都是靠把宏改成内联函数或者让调用方把副作用表达式提前算好来解决。

3.3 为什么多语句宏一定要包上do { } while(0)

假设你有这样一个宏,想实现“初始化两个字段”:

#define INIT_PAIR(a, b) a = 0; b = 0

单独调用没问题,可一旦放进if语句:

if (flag) INIT_PAIR(x, y); else ...

预处理后变成:

if (flag) a = 0; b = 0; else ...

分号把b = 0;单独另起了一行,else就跟if对不上了,编译直接报错。即使不用else,这种情况也很容易产生逻辑混乱。

业界标准解法是把多语句宏包在do { ... } while(0)里:

#define INIT_PAIR(a, b) do { a = 0; b = 0; } while(0)

展开后:

if (flag) do { a = 0; b = 0; } while(0); else ...

这是一个完整的语句,if后面跟着一个整体,else也能正确匹配。do { } while(0)不是循环,它就是构造一个独立的复合语句,同时不会引入多余的作用域问题。写多语句宏的时候,这个包裹是一个基本尊严问题。

补一句:如果你确实需要“像函数一样的宏”,又想避开重复求值、类型不安全、多语句难写这些槽点,现代 C 编译器普遍支持的static inline函数是更好的推荐。宏真正不可替代的场景是那些需要在调用点直接操作的场合,比如获取__LINE__、构造变量名,或者做类型泛型的代码生成。宏和函数不是替代关系,是各管一段。

4. 条件编译:用预处理器给代码安装环境开关

条件编译是预处理器最像“程序逻辑”的部分:它能在编译前做判断,决定哪段代码保留、哪段代码丢弃。这个机制在跨平台开发、调试模式控制、功能裁剪里是绝对的基石。我见过不少初学者在main函数里用if去“判断平台”,然后代码在另一个平台编译时直接报错,因为编译器根本不给另一个分支做语法检查,而if是要检查类型的。这种情况就得换成条件编译。

4.1 条件编译指令族:从#ifdef到#if的判断逻辑

条件编译的指令包括#if、#ifdef、#ifndef、#elif、#else、#endif。它们的核心行为是:如果条件为假,预处理器把这一段文本整体删除,就好像你从来没有写过它一样。

#ifdef用来判断“某个宏是否被定义”,#ifndef是它的反向判断。include guard 用的就是#ifndef。而#if则可以对常量表达式求值,比如:

#define VERSION 2 #if VERSION > 1 // 版本大于 1 才编译的代码 #else // 其他代码 #endif

这里要注意:#if里的表达式操作数只能由宏、常量整数和运算符构成,不能写sizeof,不能写运行时变量。因为预处理器不懂这些,它只会对整型常量表达式做算术。还有一个坑:对一个未定义的宏在#if中求值时,预处理器把它当成0,而不是报错。所以#if FOO > 1在FOO未定义时不会报错,只会走假分支。排查相关问题如果没意识到这个默认值规则,很容易一头雾水。

4.2 典型场景:调试开关、平台差异与功能裁剪

调试场景很常见。我想在代码里保留日志输出,但发布版本里全部干掉,用条件编译就很干净:

#define DEBUG_LOG_ENABLE 1 #if DEBUG_LOG_ENABLE #define DEBUG_PRINT(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__) #else #define DEBUG_PRINT(fmt, ...) ((void)0) #endif

这样在#else分支里,DEBUG_PRINT被定义成一个空操作,所有调用点在编译后不产生任何代码,连参数求值都不会发生。如果用运行时if做开关,至少要承担函数调用的开销,而且字符串常量还驻留在二进制里。

平台差异的适配也靠条件编译。比如不同的编译器对特定语法的支持不同,可以用_MSC_VER、__GNUC__这些编译器预定义宏来区分:

#if defined(_MSC_VER) #define PLATFORM_WINDOWS 1 #elif defined(__GNUC__) #define PLATFORM_LINUX 1 #endif

这里用defined()运算符让逻辑更清晰:#if defined(MACRO)和#ifdef MACRO等价,但defined()能跟&&、||、!配合,写成复杂的多条件判断。我在跨平台项目里更推荐#if defined(A) && !defined(B)这种写法,可读性强,逻辑明确。

4.3 条件编译中容易出现的诡异问题

条件编译最常见的问题是预处理器未定义宏的默认0行为。下面这段代码就是一个很好的反面例子:

#define FEATURE_A 0 #define FEATURE_B 1 #if FEATURE_A && FEATURE_B // 期望两个功能都开 #else // 实际走进了这里 #endif

如果你以为FEATURE_A和FEATURE_B里的“值”决定开关,而忘了它们是宏,那FEATURE_A被定义为0后#if条件为假,代码走了#else,这个结果可能不是你以为的“功能开关”语义。反过来说,如果一个宏完全没定义:

#if FEATURE_C // ... #endif

预处理器假设FEATURE_C等于0,整段代码不会编译。这本身不是 bug,但如果你忘了定义FEATURE_C,又没有打开任何编译警告,你可能很久都不知道这个功能已经被静默关闭了。对此我的建议是:用#if defined(FEATURE_C)或#ifdef,然后对照一个“必须定义”的宏清单,最好配构建脚本来保证这些宏必须被显式定义,避免“未定义即当 0”的静默失效。

还有一个常见问题是条件编译的边界错误。#ifdef开头了,忘写#endif,编译器会报一个指向文件尾部的莫名其妙的错误;或者在#if分支里嵌套#ifdef时漏写一个#endif,预处理器会把后续一大段内容当成注释吞掉。排查这些问题的好办法是让 IDE 折叠条件编译块,或者用编译器选项输出预处理宏定义列表,我后面会细说。

5. 预处理指令里的“隐藏技能”:#、##、__VA_ARGS__与内置宏

到这一步,你已经能应对大多数预处理编程任务了。但想要写出真正巧妙的宏,还得掌握几个“魔法”运算符和编译器预设的内置宏。这些东西在日志库、错误处理、反射式代码生成里经常能发挥奇效。

5.1 字符串化与标记粘贴:让宏学会“写字”和“拼词”

#运算符把宏参数变成一个字符串字面量:

#define STR(x) #x printf("%s\n", STR(hello world)); // 输出 hello world

注意这里实参如果包含空格会被原样保留,STR( hello world )最后生成的字符串可能带上前导空格,取决于编译器对宏参数空白的处理规则。它的典型应用是“打印变量名 + 变量值”:

#define PRINT_EXPR(x) printf("%s = %d\n", #x, (x)) int count = 10; PRINT_EXPR(count); // 输出 count = 10

这个宏把变量名以字符串形式嵌入输出,排查问题时能少打很多字。大项目里你经常能看到类似的封装。

##运算符是标记粘贴,它把左右两边的 token 粘成一个新的 token:

#define MAKE_VAR(name, id) name##id int MAKE_VAR(myVar, 1) = 42; // 展开为 int myVar1 = 42;

这个能力在做“同构代码生成”时特别有用。比如你想定义一堆结构体,每个都有对应的处理函数,可以写一个统一的宏来生成声明和定义,避免手写大量重复样板。常见的就是注册表、命令表、事件表这些场景:

#define EVENT_HANDLER(event_id) int on_event_##event_id(void) EVENT_HANDLER(click) { return 0; } EVENT_HANDLER(drag) { return 0; }

预处理后就生成了int on_event_click(void)和int on_event_drag(void)两个函数。这套玩法的本质是把代码里的重复模式交给预处理器去“印刷”,你维护的是宏定义,而不是一大片手抄代码。

5.2__VA_ARGS__与调试日志宏的实战改造

C99 引入了可变参数宏,用...表示“任意多的参数”,在宏体里用__VA_ARGS__引用它们。最经典的用途是自定义日志:

#define LOG(fmt, ...) printf("[%s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)

这里##__VA_ARGS__是 GNU 扩展,允许在可变参数为空时自动去掉前面的逗号。标准写法是:

#define LOG(fmt, ...) printf("[%s:%d] " fmt "\n", __FILE__, __LINE__, __VA_ARGS__)

但这么写,用LOG("hello")展开后会多出一个逗号,编译失败。GNU 的##__VA_ARGS__则允许你省略参数,LOG("hello")变成printf("[%s:%d] " "hello" "\n", __FILE__, __LINE__),干净利落。Clang 和 GCC 都支持这个扩展,MSVC 也兼容,但如果你要求严格 ANSI C,就得绕开它。

我在实际项目里用这个宏做日志分级,配合同样的#if条件编译,能在 release 构建里完全移除调试日志。你写日志宏的时候建议记住一点:fmt参数要当字符串拼接的首段,而不是做成printf(__VA_ARGS__),否则格式化字符串如果有%,会跟后面的参数匹配出错。日志宏模板我一般长这样:

#define LOGI(fmt, ...) log_output(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

__FILE__和__LINE__是编译器预定义的内置宏。__FILE__展开为当前源文件的字符串路径,__LINE__展开为当前行号的整数常量。它们配合“字符串化 + 可变参数”就能做出很专业的日志系统,几乎是每个 C 项目的标配。

5.3 标准预定义宏:编译器帮你准备好的“暗号”

编译器除了会处理你写的宏,还会预置一批宏。常用到的有:

  • __FILE__:当前文件名,const char *类型
  • __LINE__:当前行号,整数类型
  • __func__:当前函数名(C99 起支持,一个局部变量,不是宏,但常和预处理宏一起用)
  • __DATE__:编译日期,字符串
  • __TIME__:编译时间,字符串
  • __STDC__:编译器是否符合 ANSI C

这些内置宏能干很多事情。比如断言宏:

#define ASSERT(cond) \ do { \ if (!(cond)) { \ fprintf(stderr, "Assertion failed: %s at %s:%d in %s\n", \ #cond, __FILE__, __LINE__, __func__); \ abort(); \ } \ } while(0)

这个宏能输出最精确的失败位置,比裸写assert多了函数名,调试时能少翻几层调用栈。还有不少项目在编译时注入__DATE__、__TIME__做成build_version字符串,启动时打印出来,追踪现场二进制到底是哪个版本编译的,靠的就是这些暗号。

6. 排查清单:当预处理相关的 bug 找上门时,我一般这样查

预处理指令使用范围广、触发时机隐蔽,这个问题几乎没有一锤子解法。但按一套稳定的排查顺序来的话,大多数问题都能很快定位。这一部分我把自己的排查经验整理一下,算是给前面所有知识的实战收尾。

6.1 用gcc -E/clang -E查看预处理的“案发现场”

当你怀疑某个宏或者头文件包含出了问题,第一反应应该是去看预处理之后到底长什么样,而不是盯着源码猜。命令很简单:

gcc -E suspect.c -o suspect.i

打开suspect.i,在展开后的文件里搜索出问题的那行代码,你会看到宏被替换成了什么、头文件被插入到了哪里、条件编译到底保留了哪些分支。有一回同事说他的结构体在编译时“凭空多了个成员”,我让他生成.i文件一搜,发现是某个宏把结构体成员名给替换掉了。这种问题不展开看,用肉眼根本不可能找到答案。

如果只想看宏定义和#define开头的展开,可以这样:

gcc -dM -E - < /dev/null

这会列出编译器预置的所有宏定义。结合grep,你可以快速确认某个宏是不是系统/编译器早就定义好的,避免跟自己的宏冲突。

6.2 项目实战中的几个典型复现案例

我在嵌入式项目和通信模块里遇到过无数预处理相关的疑难杂症,挑几个常见的说:

第一类是头文件循环包含。a.h包含b.h,b.h又包含a.h。如果没有 include guard,预处理器会无限递归,直到报错;有 include guard 虽然编译能过,但某些类型定义顺序错了,导致“未声明”的错误。这时候gcc -E生成的.i文件能帮你看到头文件的嵌套顺序,结合#ifndef的宏是否在最外层被定义,就能判断哪个头文件先被打开了。

第二类是宏展开后的类型不匹配。比如一个宏展开后把unsigned int和int混在一起做比较,编译器警告“有符号/无符号不匹配”。看源码可能觉得没啥,但是展开后的宏里混着不同类型,得靠.i文件才能看清实际的比较表达式。这种情况下除了修宏,还经常要顺手统一类型定义。

第三类是条件编译分支没生效。代码里写了#if PLATFORM == 1,但预处理后那一段根本没出现,查到最后往往是因为PLATFORM宏被打包工具传成了字符串,或者拼写不一致。遇到这类问题,我习惯在代码里临时加一行#error "check PLATFORM macro",如果这个宏生效,编译就会在这里停下来,快速验证分支是否走对。#error是另一个常被忽略但实战价值很高的预处理指令。

6.3 越用越稳:一套适合自己的预处理编码约定

排查问题是一方面,从源头减少问题更重要。下面这套约定是我多年写 C 项目积攒出来的经验,不一定要求照做,但确实能让人少熬夜:

  • 头文件防护宏一律用项目名_目录_文件名_H这种带路径信息的格式,避免多项目拼接时撞名
  • 宏名全部大写,单词用下划线分隔,不跟变量、函数名混在一起
  • 函数宏的每个参数都要加括号,整个表达式最外层再加一层括号
  • 多语句宏必须包进do { } while(0)
  • 宏参数绝对不传有副作用的表达式;如果调用方非要这么干,那是调用方的问题,但宏的文档里必须写清这个禁忌
  • 能用static inline解决的,优先不用宏解决
  • 每个头文件在 include guard 下面用注释写清这个文件导出的核心类型和函数,方便别人判断要不要包含

这套约定不一定是最优解,但它能保证团队协作里最常出现的预处理问题在前置阶段就被拦下来。养成习惯后你会发现,预处理指令不但不是坑,反而是 C 语言里少有的能在编译期帮你“写代码”的工具。像我前面写日志宏、断言宏、事件表生成宏,都是在用预处理器做代码层面的自动化,这也是 C 语言的一种独特乐趣。你真把这些机制用顺了,再回头看那些宏相关的可怕的 bug,大多会变成一句轻描淡写的“哦,预处理展开后是那样,所以就不奇怪了”。

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

DRV8818PWPR+PIC18F4550双极步进电机工业级控制方案

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

作者头像 李华
网站建设 2026/10/3 7:41:09

正交试验设计与方差分析:从L9表到工艺优化的完整实践指南

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

作者头像 李华
网站建设 2026/10/3 7:40:57

基于ZYNQ7020的FPGA图像处理实战:中值滤波与膨胀腐蚀入门

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

作者头像 李华