news 2026/10/28 9:13:11

硬件I2C与软件I2C谁更坑?驱动开发中的时序、状态机与选型实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
硬件I2C与软件I2C谁更坑?驱动开发中的时序、状态机与选型实战

驱动之路第44期,想聊一个隔三差五就被拉出来吵一轮的话题:硬件 I2C 和软件 I2C 到底谁更坑。

背景是老朋友那边的一块板子,传感器挂在一路硬件 I2C 上,通信时好时坏,他第一反应就是“改成软件 I2C 算了”,结果装上去之后更崩溃——单独读寄存器地址没问题,连续读数据流的时候隔三差五丢字节,偶尔还直接把外设状态机搞乱。折腾两个晚上之后他跟我感慨了一句:这俩东西,怎么哪个都不省心。

这句话基本道出了真相。在驱动开发这个领域里,硬件 I2C 和软件 I2C 的争论永远不像表面上看起来那么两极分化。硬件 I2C 有状态机、有中断、有各种标志位,调起来像在解谜;软件 I2C 则把时序控制权完全交到你手里,任何微小的代码路径偏差都会直接变成总线波形毛刺。换句话说,二者确实都坑,但坑的位置完全不一样。

这篇文章我就把两边这些年我踩过的坑、整理过的思路一次性写出来,包括状态机异常恢复、时钟延展处理、读写收尾节奏、总线负载和上拉电阻的选择,以及最终的选型标准。希望对正在被 I2C 折磨的驱动同行有一些参考价值。

1. 先说结论:两个坑的位置不一样,别急着站队

1.1 为什么这个争论永远吵不出结果

只要在嵌入式群里待过一段时间,你就会发现关于 I2C 的讨论永远分成两派。一派说“硬件 I2C 是标配,状态机帮你处理了大部分时序,效率高稳定性好”,另一派说“硬件 I2C 的 BUSY 位和错误中断能把人逼疯,我从来都是 GPIO 模拟,代码想怎么调怎么调”。

其实两派都对,前提是他们各自的场景都成立。硬件 I2C 的可靠性建立在“外设正常工作 + 你正确配置 + 总线没有异常”这个前提上,而软件 I2C 的可靠性建立在“你的 CPU 时间片完全可控 + 中断不会乱插入 + 对端设备按规范操作”这个前提上。问题就在于,嵌入式系统里这两个前提往往都很难长期成立。

我自己的判断比较偏向一个更实际的说法:硬件 I2C 的坑主要集中在外设状态机内部,一旦进去异常分支,恢复流程比正常通信还难写;软件 I2C 的坑主要集中在时序完整性上,它不会突然死锁,但会在应用层表现为各种随机故障,排查成本极高。

1.2 本质差别决定了坑的形态

硬件 I2C 的本质是一个独立于 CPU 运行的串行通信状态机。你只需要往数据寄存器里填东西,然后等待事件标志位翻转,剩下的电平变化、时钟节拍、起始停止条件都由内部硬件逻辑完成。看起来确实省心,但状态机有一个典型问题:它一旦因为某种原因进入了非预期状态,就不会像普通代码那样“函数出错返回”了事,而是会一直卡在某个标志位上,比如 BUSY,直到你主动把它复位。

软件 I2C 的本质则是用 GPIO 按时间顺序模拟每个bit的电平变化。拿读取一个字节来说,CPU 要依次完成拉低 SCL、释放 SDA、采样 SDA、拉高 SCL、重复多次这样的小动作。这套循环本身不难,但它对执行连续性极其苛刻。只要中途有任何一次高优先级中断插入,后续的时序就会向后平移,如果恰好跨越了采样点,读进来的就是错 bit。

同一个 I2C 总线,硬件方案把状态管理交给了芯片内部的自动机,软件方案把状态管理交给了你的 C 代码。坑是逃不掉的,只是从左边搬到了右边而已。

2. 硬件 I2C 的坑集中在状态机:异常分支里的自救难度很高

很多人一开始选硬件 I2C,是看中它“自动处理时序”的优势。但真正被硬件 I2C 折磨过一段时间之后你会发现,它最大的问题也来自这种“自动”。外设状态机把底层细节全部封装起来,一旦出错,你很难确定它现在到底处在收发流程的哪个阶段。

2.1 永远等不到释放的 BUSY 位,以及它背后的总线死锁

我在 STM32F1 平台上第一次调硬件 I2C 时,最经典的一段代码长这样:

while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY));

如果某个时刻总线因为外部干扰、从机异常拉低 SCL 或者 NACK 之后状态机没有正确退出,那么依赖这条语句的代码就会直接卡死在原地,整个任务死锁。而且这个 BUSY 标志位一旦置上,单纯的重新初始化外设可能都无效,因为硬件认为“总线正被另一个主机占用”,只有等它变为空闲才能重新接管。

最麻烦的情况是,如果对端设备因为检测到非法起始/停止条件而进入错误状态,它会持续拉低 SDA 或者 SCL。此时总线从硬件状态机的视角看,永远处于忙状态,代码再怎么写等待都是白搭。

这种情况下我常用的恢复手段是:把 I2C 引脚重新配置成普通 GPIO 开漏模式,手动模拟 SCL 翻转 9 个周期,把挂在总线上的设备从未知状态里“踢出来”,再把引脚恢复成 I2C 复用功能并重新初始化外设。代码思路大致如下:

static void i2c_bus_recover_gpio(void) { gpio_init_as_od(&SCL_PIN, 1); gpio_init_as_od(&SDA_PIN, 1); gpio_set(&SCL_PIN, 0); gpio_set(&SDA_PIN, 1); delay_us(5); for (int i = 0; i < 9; i++) { gpio_set(&SCL_PIN, 1); delay_us(5); gpio_set(&SCL_PIN, 0); delay_us(5); } gpio_set(&SDA_PIN, 0); /* 产生一个 STOP 条件 */ delay_us(5); gpio_set(&SCL_PIN, 1); gpio_set(&SDA_PIN, 1); delay_us(5); }

这个恢复函数是我在产线不良品上试出来的。当时分析坏板现象,发现从设备卡死在半字节状态,普通重新初始化无法解决,只有手动产生总线复位电平才能让它恢复正常。这给了我一个教训:硬件 I2C 的驱动,不能只写“正常通信”这一条路径,必须把“异常恢复”当成一等公民对待。

2.2 NACK 之后的标志位残留,是硬件 I2C 的第二大坑

另一个经常被忽略的问题是 NACK 处理。比如你向一个不存在的地址发起写操作,或者从机在忙时拒绝接收数据,总线上会返回一个 NACK。硬件 I2C 外设会把这个 NACK 作为错误事件上报,并且把对应的错误标志位置上。

如果你在上报中断之后只读一下数据寄存器,没有把错误标志位彻底清掉,那么下一次通信很可能直接被跳过或者立即误触发错误中断。这个现象在连续读写多个寄存器地址时尤为明显,因为状态机还停留在一半流程里没走出来,新命令根本占不进去。

我现在的建议是:凡是硬件 I2C 的错误中断处理函数里,清标志位一定要放到最后再清,并且把数据寄存器、状态寄存器全部读一遍之后再统一清除,避免只清了部分标志导致的残留问题。另外,所有通信函数都应该带上超时退出,不可以在 BUSY、ERROR 这类标志位上无限等待。这个点放在后面统一说。

2.3 DMA 配合下的硬件 I2C,问题更加隐蔽

引入 DMA 之后,硬件 I2C 的坑会再深一层。DMA 负责在内存和外设之间搬运数据,CPU 不再参与每个字节的传输,但是 DMA 的完成中断、传输错误中断和 I2C 自身的错误中断是分开的。一旦 I2C 在 DMA 传输过程中发生 NACK,就会产生两个信号:I2C 侧错误事件、DMA 侧传输错误事件。两个中断处理函数可能会互相竞争,导致状态机该退出的没退出,DMA 该停的没停。

这类问题最尴尬的地方在于,裸机主循环下它可能几天都不出现,但只要跑 RTOS 或者频繁开关外设,就有机会在某个边界时间点触发。调试时你把逻辑分析仪挂上去看波形,又发现波形本身并没有明显错误,因为问题发生在中断竞争层面。

处理思路是先分清处理层级:I2C 主状态机负责通信,DMA 只负责数据搬运。然后停止 DMA 传输时应同时关闭 DMA 通道和 I2C 外设,再做总线恢复,次序不能乱。我在实践中总结了一个固定顺序:

  • 第一步,停止 DMA 通道;
  • 第二步,关闭 I2C 外设;
  • 第三步,清空 I2C 所有状态位;
  • 第四步,重新初始化。

如果颠倒前两步顺序,可能 DMA 还在半途搬运时就触发了新的错误事件,导致恢复流程被反复打断。

3. 软件 I2C 的坑集中在时序完整性:随机故障很难复现

软件 I2C 给人的第一印象是“代码完全可控”,因为它没有状态机、没有外设寄存器、没有 BUSY 位。但实际上它把风险转移到了另一个地方:总线波形的正确性完全取决于以下代码是否在正确的时间点执行。

3.1 中断抢占会让波形发生微小偏移,且故障随机出现

我在一个自由 RTOS 项目里,用软件 I2C 驱动一颗环境传感器。单独测试传感器的时候一切正常,但接入系统后偶尔会读到完整为 0xFF 或 0x00 的数据块。刚开始我怀疑是传感器坏了,后来用逻辑分析仪抓波形,发现问题是中断抢占。

当时有一个周期性的定时器中断,每次进来要执行约 20 微秒的处理。而我的软件 I2C 在读取一个字节时,正好在某个 bit 的 SCL 下降沿之前被中断打断,中断返回之后代码继续往下执行,但波形已经向后偏移了。如果这个偏移恰好超过了从机的数据保持时间窗口,采样就会出错。这类错误最大的特点就是概率低、随机性强、不一定每次都出现。

解决手段是给软件 I2C 的字节收发过程加临界区保护,也就是在收发期间屏蔽可能干扰时序的中断。但这里有个度的问题,如果屏蔽时间太长,会破坏系统的实时性;如果只屏蔽一部分,还是可能有漏网之鱼。

我的做法是把临界区保护粒度控制在“一个字节”级别。因为单个字节的最长收发时间是可以计算出来的,比如 100 kHz 下,一个字节加 ACK 约 90 微秒,这个屏蔽时间放在多数系统里是可以接受的。读写 buffer 之间再把中断打开,避免长数据传输时长时间关中断导致任务调度异常。

3.2 时钟延展是软件 I2C 最容易忽略的盲区

很多传感器为了处理内部数据转换,会在主机读取某寄存器时主动拉低 SCL,以延长时钟周期,这个行为叫时钟延展(clock stretching)。硬件 I2C 外设对这一情况的处理相对成熟,但软件 I2C 的代码如果不做特殊处理,就会出现问题。

具体来说,标准 bit-bang 代码在发送时钟时,通常是:

I2C_SCL_H(); delay_us(5); I2C_SCL_L();

它假设拉高 SCL 后会等待固定时间再拉低。如果从设备此时拉低了 SCL,那么主机的“拉高”实际上被从设备强制延迟了,可你的 delay 仍然按固定时间计时,紧接着就把 SCL 拉低了。这时候从设备还没准备好数据,采样自然出错。

正确的做法是在每个 SCL 高电平期间加入对 SCL 引脚的检测,只有当 SCL 真正为高时才继续,否则一直等。以读一个 bit 为例:

static uint8_t i2c_read_bit(void) { uint8_t bit = 0; I2C_SDA_IN(); I2C_SCL_H(); /* 等待从机释放 SCL,期间限制最大等待时间 */ uint32_t timeout = 1000; while (i2c_scl_read() == 0 && timeout--); if (timeout == 0) { return 0xFF; /* 超时按错误处理 */ } bit = i2c_sda_read(); delay_us(1); I2C_SCL_L(); I2C_SDA_OUT(); return bit; }

加了这段等待逻辑之后,只要从机的时钟延展时间不超过超时上限,通信就不会被破坏。这个知识点在硬件 I2C 下你不需要关心,因为硬件会自动处理,但是在软件 I2C 下必须自己实现。这也是我说“软件 I2C 把麻烦从状态机转移到了时序代码”的主要原因。

3.3 多字节读取时,最后一个字节的 ACK/NACK 节奏非常关键

软件 I2C 另一个高频翻车点是多字节连续读取的收尾。I2C 规范规定,主机在读取最后一个字节时必须发送 NACK 而不是 ACK,然后再发送 STOP 条件。如果主机在读完最后一个字节时仍然发 ACK,从机会认为主机还要继续读取,于是继续把下一个字节放到总线上,双方节奏直接错乱。

这个问题在简单驱动里特别常见,因为很多参考代码把“读字节”函数封装成统一格式,每次读到数据后都自动回 ACK,然后回到主循环再判断是否读完。结果就是每次最后一个字节的读取时序都出错,但对某些对端设备来说它有可能是兼容的,导致问题时隐时现。

我的软件 I2C 读函数固定写成这样:

static int i2c_read_buffer(uint8_t addr, uint16_t reg, uint8_t *buf, uint16_t len) { if (i2c_start() != 0) return -1; if (i2c_write_byte(addr << 1 | 0x00) != 0) { i2c_stop(); return -1; } if (i2c_write_byte(reg & 0xFF) != 0) { i2c_stop(); return -1; } if (i2c_restart() != 0) { i2c_stop(); return -1; } if (i2c_write_byte(addr << 1 | 0x01) != 0) { i2c_stop(); return -1; } for (int i = 0; i < len; i++) { buf[i] = i2c_read_byte(); if (i < len - 1) { i2c_send_ack(); } else { i2c_send_nack(); /* 最后一个字节必须 NACK */ } } i2c_stop(); return 0; }

注意中间那个 restart 条件也要处理好,很多参考代码把 restart 直接写成 start,这在某些从机上会被误判为一次完整通信结束,导致寄存器地址指针丢掉了。

4. 回到总线本身:速率、上拉电阻和多设备共存时的差别

前面说的都是驱动代码层面的问题。但如果从整个 I2C 总线的角度看,硬件 I2C 和软件 I2C 在总线物理层的表现也有明显差异,这个差异往往决定了哪个方案更适配你的硬件环境。

4.1 频率越高,软件 I2C 的劣势越明显

I2C 标准模式是 100 kHz,快速模式是 400 kHz。硬件 I2C 靠内部分频和硬件计数器来保证时钟周期,跑 400 kHz 并不困难,有些 MCU 甚至能跑到 1 MHz。软件 I2C 则受限于 GPIO 翻转速度和代码效率。

我在 ARM Cortex-M 上做过简单测试,裸跑一个 GPIO 翻转加一个空循环,100 kHz 还可以做到相对稳定;到 400 kHz 时,如果中途还要判断 SDA 数据和处理分支跳转,代码指令周期就已经超过一个 bit 周期了。强行跑到 400 kHz 的结果是波形占空比严重失衡,SCL 高电平时长和低电平时长差异巨大,部分从机设备会直接不工作。

如果设计里确实需要 400 kHz,我的建议是直接使用硬件 I2C。如果对成本更敏感或者引脚资源受限必须用软件模拟,那么请把速率目标定在 100 kHz,或者采用超低延迟的 GPIO 直接操作方式,并且严格保证中断屏蔽。

4.2 上拉电阻和总线长度对两边的影响方式不同

I2C 总线需要外部上拉电阻才能工作,常见配置是 4.7 kΩ 或 10 kΩ。上拉电阻的选取原则是:阻值越小,上升沿越快,但总线漏电流越大;阻值越大,功耗越低,但上升沿时间越长。

在硬件 I2C 下,上升沿时间过长会导致高电平采样窗口不足,外设对输入电平的判断出现偏差,但通常仍然有一个相对宽容的阈值范围。在软件 I2C 下,如果上升沿太慢,你写的固定延时参考、GPIO 读取结果可能恰好落在信号还没完全变高的时间段,读到的电平是 0,直接造成误判。

所以如果你是软件 I2C 方案,建议对总线上拉电阻做一个实际测量。简单做法是拿示波器看 SCL 上升沿时间,确保这个时间远小于你代码里 SCL 高电平延时的一半。如果条件限制没有示波器,那就干脆把上升沿时间预算留大一些,降低总线速率。

4.3 多设备同挂一条总线时,硬件和软件的表现差异更大

产品上经常会把 EEPROM、温度传感器、触摸屏控制器挂同一条总线上。硬件 I2C 在应对这类多设备场景时明显更稳,因为外设管理器会主动记录当前通信状态,你每次通信前只需要按正常流程启动即可,不用担心上一段通信残留的状态。

软件 I2C 在多设备场景下更容易出问题,因为你在切换设备地址时需要处理完整的起始条件、地址发送、寄存器指针设置等流程,只要中间某个函数写得不严谨,就可能把上一次通信的残余状态带进下一次。尤其是遇到那种在 DAT 采样期间不释放 SDA 的老旧传感器,连续切换多个地址时,软件 I2C 很容易出现“ACK 收到但数据错误”的诡异现象。

我在一个项目里用软件 I2C 同时驱动三颗传感器,为了保证稳定性,最终做了如下妥协:每颗传感器之间的通信间隔加长到 5 ms 以上,并且在读取函数开头总是刷新一次总线状态,先检查 SDA 和 SCL 电平是否都处于空闲高位,如果不空闲就先做一次 9 脉冲恢复再继续通信。

5. 我现在做驱动时的选型思路,以及几个真正有效的调试习惯

讲了这么多坑,最关键的问题还是要回答:到底怎么选?我自己在驱动选型阶段有一个比较固定的判断流程,按照下面的顺序逐一过一遍,基本不会踩到最折磨人的那种坑。

5.1 五条筛选原则

  • 总线速率需求大于 200 kHz 时,直接选硬件 I2C;
  • 项目使用 RTOS 且任务切换频繁,优先硬件 I2C,因为软件 I2C 的中断屏蔽时间容易拖垮调度;
  • MCU 芯片勘误表里如果提到硬件 I2C 存在已知的复位问题,同时又没有现成的恢复方案,再考虑软件 I2C;
  • 单条总线上挂载设备数量超过三个,优先硬件 I2C;
  • 低功耗待机场景且需要软件唤醒后快速恢复通信,二者都可以,但必须把总线恢复代码放在初始化最前面。

可能有人会觉得这套规则太保守,总是偏向硬件 I2C。其实并不是硬件 I2C 绝对更好,而是在同等条件下,硬件 I2C 的失败模式更集中、更容易用超时和保护逻辑兜住;软件 I2C 的失败模式则分散在任意一个中断插入点,排查效率比较低。

5.2 超时保护:两套方案都必须加的底线

不管最终选了硬件还是软件 I2C,我都会保证所有等待标志位的地方都有超时退出逻辑。这一段是我在多个项目里反复验证后的产物,模板大致如下:

static int i2c_wait_flag(uint32_t flag, uint32_t timeout_ms) { uint32_t t0 = get_tick_ms(); while (i2c_flag_ready(flag) == 0) { if (get_tick_ms() - t0 > timeout_ms) { i2c_bus_recover(); return -1; } } return 0; }

一个简单的超时函数,能在状态机卡死、从机不响应、总线被拉低等绝大多数异常场景下兜底。千万不要用那种裸循环等标志位的写法,因为嵌入式设备运行时间一长,总线上什么意外都有可能发生,没有超时保护就是在给未来埋故障。

5.3 逻辑分析仪优先于示波器,抓波形再看代码

排查 I2C 问题时,我的习惯是先上逻辑分析仪,用足够高的采样率(建议 8 M 以上)抓一段完整波形,放大看每一个 START、STOP、ACK/NACK 和字节数据。逻辑分析仪的优势是可以长时间连续记录,软件 I2C 那种“偶尔丢一个 bit”的错误只能靠长时间抓取才能遇到。

示波器当然更精确,在大规模生产调试时不可替代,但大部分日常驱动调试场景下,逻辑分析仪的性价比和效率反而更高。抓完波形之后对照 I2C 协议逐一检查,基本能确定是主机时序问题还是从机响应异常,再回到代码里定位。

5.4 一个项目里硬件和软件 I2C 共存的例子

我在某块主控板上做过一个组合方案:一路硬件 I2C 放在 400 kHz,挂 EEPROM 和高精度温湿度传感器;另一路软件 I2C 放在 100 kHz,挂一颗不那么较真的触摸按键芯片。硬件那路用中断+轮询处理,软件那路只在一段不受中断干扰的初始化阶段使用。

这样组合的实际效果是,硬件那路吞吐量和稳定性都得到了保证,软件那路虽然速度慢,但它的对端设备本身就不依赖高速通信,并且触摸按键芯片对时序要求低,用 delay 模拟也不会出错。这个案例说明,选型不一定是二选一,完全可以按设备和场景分而治之。

6. 写在最后的个人经验

硬件 I2C 和软件 I2C 的争论大概率会一直持续下去。我的态度是:不神化硬件 I2C,也不鄙视软件 I2C,而是把两个方案各自的失败路径摸透,然后让驱动的每一层都有兜底措施。

我个人的习惯是:能用硬件 I2C 时尽量用硬件,但每个通信函数都带超时和恢复逻辑;软件 I2C 只留给低速、低复杂度的从机设备,并且把中断屏蔽时间、时钟延展等待、最后一字节 NACK 收尾这些细节全部提前固化到代码模板里。这样即使中途出差错,问题范围也被限制在可排查的区间内。

如果你现在正被某颗 I2C 从机折磨得想砸板子,不妨先停一下,用逻辑分析仪抓一段完整波形,把 START、地址、数据、ACK、STOP 一个个对应出来看。很多时候驱动反复调不通,不是芯片不配合,而是我们没有给它足够清晰的定义和足够的出错预留空间。把总线当成一个同样会犯错的部件来对待,问题往往就会好解决得多。

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

5G物理层算力瓶颈怎么破?ACAP异构计算架构与Versal开发实战

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

作者头像 李华
网站建设 2026/10/28 9:08:42

电子制作课程教学:从理论到工程实践的能力培养路径

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

作者头像 李华
网站建设 2026/10/28 9:08:26

职业教育立体化教材开发指南:从数字资源到虚拟仿真的完整路径

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

作者头像 李华
网站建设 2026/10/28 9:08:14

GPIO驱动实战进阶:从寄存器到设备树,吃透模式与调试

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

作者头像 李华
网站建设 2026/10/28 9:07:39

ESP32 刷固件会变砖吗?双分区+自动回滚机制详解

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

作者头像 李华
网站建设 2026/10/28 9:06:48

蓝牙MCU集成方案与低功耗设计:CH592选型及调试实战

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

作者头像 李华