DHT11单总线通信拆解:从时序原理到驱动代码实战
1. 为什么DHT11这么“老”了还是绕不开一点心里话如果你在嵌入式社区混过一阵子大概率会看到两种截然不同的态度一边是新手拿着DHT11做温湿度计、智能家居节点另一边是“老炮”不屑一顾地说这玩意儿精度一般、时序别扭、单总线还娇气随便来个中断就把时序打乱了不如直接上I2C的SHT30。但我说句实在话DHT11仍然是目前入门单总线通信最合适的一个载体。原因不复杂——它慢、它简单、它便宜而且它的协议足够“原生”没有硬件控制器帮你兜底每一位都得靠GPIO翻转和延时函数去抠。你把DHT11的时序彻底吃透了回头看DS18B20、看各种单总线温湿度传感器甚至自己用一根IO口去模拟一些低速的专用协议都会顺畅很多。这篇文章我打算把DHT11的器件原理、单总线通信协议、驱动代码一次讲透按照“器件本身 → 协议机制 → 代码实现 → 实测踩坑”的顺序走一遍。内容面对的是已经会一点GPIO操作、想搞明白“为什么别人写的驱动要那样写”的开发者也适合刚接触单片机不久、准备用STM32或国产MCU做一个小项目的朋友。先说一个很容易劝退新手的点DHT11的数据手册是中文的翻译质量还行但时序图画得比较“抽象”很多关键参数散落在各处一开始读很容易懵。我这篇文章会把时序参数整理成表格对照着代码讲尽量让你看完就能自己写一套驱动出来而不是只会复制粘贴。2. DHT11到底是个什么器件从内部结构到电气特性2.1 引脚定义和典型接法DHT11常见的有三脚封装和四脚封装买的时候注意看丝印。三脚封装从左到右一般是VCC电源、DATA数据、GND地四脚封装有一个脚是悬空的NCNot Connected其实用到的还是那三根。供电范围很宽3.3V到5.5V都能跑。但这里有个非常重要的细节如果用3.3V供电上拉电阻建议用4.7kΩ到10kΩ之间偏小的值如果用5V供电4.7kΩ到10kΩ都可以看你MCU的IO耐压和逻辑电平是否兼容。5V供电时DATA引脚输出的高电平也是接近5V的如果你的MCU是3.3V系统且不是5V tolerant的引脚那就必须做电平转换或者干脆选择3.3V供电否则长期使用有损坏IO的风险。数据引脚必须接一个上拉电阻因为单总线通信的特点是线与——主机和从机都可以拉低总线释放总线时靠上拉电阻把电平拉高。没有上拉电阻总线就永远停在低电平通信直接失败。2.2 内部构成和测量原理DHT11内部实际上集成了一个电阻式感湿元件和一个NTC测温元件加上一个8位单片机做信号处理和单总线协议编码。所以它对外输出的不是模拟量而是已经编码好的数字信号这一点比早期的HS1101这类需要配合555电路测量频率的湿度传感器要友好太多。测温范围0°C到50°C精度±2°C湿度范围20%RH到90%RH精度±5%RH。采样周期大概是1秒一次也就是两次读取之间如果间隔太短第二次很可能会拿到上一次的缓存数据甚至读到相同数据这是正常现象不是传感器坏了。分辨率上湿度是1%RH温度是1°C数据格式是整数位小数位但DHT11的小数位恒为0这一点从协议解析的数据结构就能看出来——前两个字节是湿度整数和小数后两个字节是温度整数和小数DHT11实际输出时小数位是零但协议上保留了位置给后续改进型号DHT22也叫AM2302用。有一点值得展开说为什么DHT11的采样周期是1秒左右因为湿敏电容需要时间与环境湿度达成平衡热敏电阻也有热惰性传感器本身做了限速处理。我在项目里曾经以500ms周期连续读取DHT11结果数据好几分钟都不变化一度怀疑代码有问题后来查资料才确认是传感器自身更新频率的限制。所以设计采集逻辑时采集周期设为1秒以上即可刻意调快没有意义白白浪费总线时间。2.3 常用参数速查表参数项目数值说明供电电压3.3V ~ 5.5V建议3.3V或5V注意IO电平匹配温度测量范围0 ~ 50°C超出范围精度不保证温度精度±2°C典型值实际可能略好湿度测量范围20%RH ~ 90%RH低于或高于此范围不建议使用湿度精度±5%RH典型值采样周期约1秒读取间隔小于1秒时数据可能不更新数据位宽40 bit湿度16位温度16位校验8位通信方式单总线一根数据线半双工主机主动发起等你把这套表记熟了选型的时候就能很快判断DHT11能用在什么场景不能用在什么场景。比如要做一个冷链运输记录仪温度范围会到零下20°CDHT11就扛不住了这时候得换DHT22或者DS18B20如果只是做一个室内环境监测小摆件DHT11绰绰有余。3. 单总线协议拆解每一次电平跳变都是有含义的3.1 单总线通信的整体流程DHT11的单总线协议属于严格的主机主动式通信从机永远不主动发言所有通信都由主机发起。一次完整的通信过程分为三个阶段主机发出起始信号Start Signal从机DHT11发出响应信号Response Signal从机连续发送40位数据每一位都是“低电平高电平”的组合形式三个阶段之间没有额外的握手没有ACK没有重传机制全靠电平的宽度说话。所以时序参数的精度直接决定通信是否成功靠通讯协议本身纠错是不现实的唯一能做的就是在读取完成后用校验字节验证数据是否有效无效则丢弃这一帧。这里其实暴露出单总线协议的脆弱性它适合短距离、低速、单点通信场景不适合长线、多节点、强干扰环境。如果你要把传感器引线放到1米以上优先考虑用屏蔽线或者干脆换成I2C总线加上PCA9600这类总线缓冲器不然时序很容易被线缆电容拉垮。3.2 起始信号和响应信号的理解主机要发起通信时先把数据线拉低保持至少18微秒最长不超过30微秒然后再释放总线上拉电阻把总线拉高。这一个“先低后高”的脉冲就是起始信号。之后主机释放总线并切换到输入模式等待DHT11的响应。DHT11会在主机释放总线后等待一小段时间然后主动把总线拉低保持80微秒左右再释放总线又产生一个80微秒左右的高电平这就是响应信号。有一个容易忽略的细节主机拉低这个动作时序长短在不同资料里写法不太一样有的写“至少18ms”这明显是笔误实际是18微秒。如果按毫秒来延时整个通信会超时到无法完成。我第一次自己写驱动时就是看了一份写错的资料把18us写成了18ms结果传感器永远没有响应排查了很久才发现是单位错了。还有个细节是主机释放总线后需要几十微秒的切换时间才能把GPIO从输出模式切到输入模式并稳定读取。如果切换得太快读到的高电平可能还是总线残压或者刚好读到DHT11开始拉低的边缘导致误判。3.3 数据位“0”和“1”的区分逻辑这是单总线协议最核心的部分也是新手最容易弄混的地方。DHT11发送每一位数据时总线状态都是相同的先是50微秒左右的低电平然后是高电平。区别只在高电平的持续时间上数据位“0”低电平50us高电平26~28us数据位“1”低电平50us高电平70us左右换句话说DHT11用“高电平的宽度”来编码0和1而不是用电平本身。主机读取的时候先等低电平结束然后开始计时直到总线再次变低记录高电平持续了多少微秒。如果高电平时间比某个阈值通常取40us或50us长就判定为1否则判定为0。这个采样思路在代码里实现为uint8_t dht11_read_bit(void) { uint8_t timeout 0; // 等待低电平结束 while (gpio_read(DHT11_PIN) 0) { if (timeout 100) return 0xFF; // 超时 delay_us(1); } // 低电平结束后开始计算高电平持续时间 uint32_t high_time 0; while (gpio_read(DHT11_PIN) 1) { if (high_time 100) return 0xFF; delay_us(1); } return (high_time 40) ? 1 : 0; }阈值为什么取40微秒而不是35或45因为数据0的高电平典型值是26~28微秒数据1的高电平典型值是70微秒中间有大约40微秒的判读裕量。取40微秒做阈值能容忍一定的时序漂移同时又不会把0误判成1。这属于工程经验值数据手册不会告诉你但实测下来很稳。3.4 40位数据帧的字节划分与校验40位数据的排列顺序是湿度整数8位湿度小数8位温度整数8位温度小数8位校验和8位校验和的计算规则很简单把前四个字节相加取低8位如果等于校验字节则数据有效。比如某次读到的湿度整数是45湿度小数是0温度整数是26温度小数是0则45 0 26 0 71校验字节应该是71否则帧无效。这个校验算法非常朴素但它能检出绝大多数单bit翻转错误。如果两个字节同时错误且恰好相互抵消校验会漏过去但这种概率在单总线这种短距离通信场景里低到可以忽略。数据接收完之后主机不需要发送任何结束信号总线自然释放即可。DHT11也不会持续占用总线。整个通信耗时大约4毫秒左右主要是起始信号1毫秒不到响应信号0.2毫秒40位数据每位约0.1毫秒加起来在3到5毫秒之间。3.5 时序参数速查表时序阶段参数典型值允许范围主机起始信号低电平拉低时间20us18us ~ 30us从机响应低电平拉低时间80us75us ~ 85us从机响应高电平拉高时间80us75us ~ 85us数据位0高电平高电平宽度27us24us ~ 30us数据位1高电平高电平宽度70us68us ~ 75us数据位低电平每位前导低电平50us48us ~ 55us对照这张表再去看时序图就清晰很多了。后面的代码里我会用这张表的参数作为延时基准。4. 代码实战从零手写一套可移植的DHT11驱动4.1 硬件环境和软件框架约定我先说明一下代码的运行环境假设MCU采用STM32F103系列标准外设库或HAL库均可GPIO用PB12作为数据线系统时钟72MHz。代码风格上我会尽量写成“弱硬件依赖”让你只需要修改几个宏和底层GPIO操作函数就能移植到其他MCU上。在动手之前先把GPIO的方向切换搞清楚。DHT11通信过程中主机需要做两件事输出起始信号输出模式拉低一定时间、读取数据输入模式。所以代码里必须有一个函数能把同一根GPIO在输入和输出模式间切换这是单总线驱动的基础。STM32标准库里的KZ_GPIO_SetMode函数大概是这样void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); } void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStructure); }如果是HAL库区别在于初始化结构体的字段名略有不同但思路一模一样。还有一个小技巧输出模式用推挽输出输入模式用浮空输入因为外部已经有上拉电阻了浮空输入完全够用。如果你非要用上拉输入模式也可以但要注意MCU内部上拉电阻一般较弱30kΩ到50kΩ与外部4.7kΩ并联后总电阻更小电平恢复速度更快但功耗略增一般项目里无所谓。微秒级延时函数是另一个依赖项。STM32标准库的SysTick默认配置是1毫秒中断一次精确到微秒需要自己写一个采用SysTick的24位向下计数器配合72MHz主频实现如下void delay_us(uint32_t us) { uint32_t start SysTick-VAL; uint32_t ticks us * 72; uint32_t now; while (1) { now SysTick-VAL; if (start now) { if (start - now ticks) break; } else { if (start (0xFFFFFF - now) ticks) break; } } }这段代码没有开启SysTick中断只用了它的计数功能不会干扰系统其他中断稳定性实测可以。如果移植到其他平台可以用定时器或__NOP()循环做延时但要先用自己的逻辑分析仪或者示波器校准延时的实际长度不能只看理论值。我之前在GD32上移植时发现同样的72MHz主频下NOP循环延时比STM32快了不少需要重新校准这说明不同内核的指令周期和Flash等待周期不一样延时函数的可移植性比想象中要差。4.2 读取一帧数据的完整驱动以下代码是读取核心整个流程严格按照前面的时序分析设计#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_12 uint8_t DHT11_ReadData(uint8_t *humidity_int, uint8_t *humidity_dec, uint8_t *temperature_int, uint8_t *temperature_dec) { uint8_t data[5] {0, 0, 0, 0, 0}; uint8_t i, j; // 1. 主机拉低起始信号 DHT11_Pin_Mode_Output(); GPIO_WriteBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN, Bit_RESET); delay_us(20); // 2. 释放总线切换输入 GPIO_WriteBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN, Bit_SET); DHT11_Pin_Mode_Input(); // 3. 等待响应信号的低电平 uint32_t timeout 0; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1) { if (timeout 100000) return 1; // 超时约1ms } // 4. 等待响应信号的低电平结束 timeout 0; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 0) { if (timeout 100000) return 2; } // 5. 等待响应信号的高电平结束 timeout 0; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1) { if (timeout 100000) return 3; } // 6. 连续读取40位数据 for (j 0; j 5; j) { for (i 0; i 8; i) { uint8_t bit DHT11_ReadBitWithTimeout(); if (bit 0xFF) return 4; data[j] (data[j] 1) | bit; } } // 7. 校验和验证 if (((data[0] data[1] data[2] data[3]) 0xFF) ! data[4]) { return 5; } // 8. 输出数据 *humidity_int data[0]; *humidity_dec data[1]; *temperature_int data[2]; *temperature_dec data[3]; return 0; }这段代码里的超时判断为什么用循环计数而不是精确延时因为阻塞式等电平变低时电平可能永远不变如果不用超时主控就直接死循环了。这种死循环风险在工程里是不能接受的尤其是有看门狗的场景超时需要尽快退出并返回错误码让上层决定是重试还是放弃。读位的函数可以这样实现uint8_t DHT11_ReadBitWithTimeout(void) { uint32_t timeout 0; // 等待低电平结束 while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 0) { if (timeout 100) return 0xFF; delay_us(1); } // 低电平结束后计算高电平持续时间 timeout 0; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1) { if (timeout 100) return 0xFF; delay_us(1); } return (timeout 40) ? 1 : 0; }关于delay_us(1)套在循环里的性能问题我想多说一句。因为每次循环都调用延时函数函数调用本身的开销会导致判定阈值的实际值偏移。如果怕偏移可以把延时函数改成宏定义或者内联函数让编译优化掉一部分开销。实测在STM32F103的72MHz主频下用上述写法判定成功率很高不需要过度优化。4.3 为什么“先等低电平结束再计时”而不是“直接测高电平”很多第一次接触这个驱动的朋友会问数据位开始时总线是低电平那我能不能直接在发送完起始信号后就开始高速采样记录整个波形然后从波形里识别0和1当然可以逻辑分析仪就是这个思路。MCU代码里也可以这么做但没必要。因为MCU烧录到不同主频的芯片上时如果采用连续采样采样率的差异会直接影响对脉冲宽度的判断而这个“先等低电平结束再计时高电平”的方式天然对低电平期间的抖动不敏感只需要在高电平期间计时阈值依然是40us容错性更好。反过来如果从高电平开始时就计时你会多等一个50us的低电平时间白白增加耗时不谈还要额外写一个“先等待高电平到来”的状态机代码就复杂了。所以业界普遍采用“等低结束→测高宽度→判决”这个套路。4.4 代码移植到其他平台时的改动点换平台时你需要改动的点非常集中DHT11_Pin_Mode_Output和DHT11_Pin_Mode_Input两个函数改成目标平台GPIO方向切换的实现。GPIO_ReadInputDataBit宏改为目标平台的读引脚函数。delay_us改成目标平台的微秒延时实现并且用示波器先校准。DHT11_GPIO_PORT和DHT11_GPIO_PIN两个宏改为目标引脚。本质上驱动的逻辑层时序状态机和数据解析与硬件层引脚操作和延时是分离的只要守住这个边界移植就很顺畅。如果项目里已经用了一个抽象层比如HAL库的GPIO读写回调同样可以只改底层回调。5. 我实际项目中踩过的坑和排查思路5.1 读取一直超时不是代码问题而是上拉电阻没焊有一次我在一个温湿度采集板上调试DHT11发现程序一直卡在“等待响应信号”的超时里。检查了一遍代码时序延时都对GPIO配置也对就是读不到低电平。后来拿万用表量了一下DATA引脚的电平发现一直悬空再查原理图元器件封装里上拉电阻的位号没贴料。这个事给我印象很深单总线协议对硬件上拉电阻是硬依赖没有它主机释放总线后电平就是悬空的既不是高也不是低读回来的值完全不可控。排查这类问题时第一个动作应该是拿万用表或示波器看总线的静态电平而不是反复检查代码。5.2 高电平只有2.8VMCU逻辑电平不兼容另一个项目里DHT11用5V供电MCU用3.3V供电数据线直接连到了MCU的普通IO口。结果时而读到正常数据时而校验失败且数据偶发出现0xFF。用示波器一看DHT11输出的高电平接近5VMCU的IO口确实能识别高电平大多数3.3V MCU的高电平阈值在2.0V左右问题在于5V电平对MCU内部的ESD保护二极管产生了额外压力长期跑不稳偶尔把引脚电平拉到临界区间。解决方法是加一个电平转换或者直接用3.3V给DHT11供电。3.3V供电时DHT11的数据输出高电平接近3.3VSTM32 GPIO识别毫无压力实测通信非常稳定。如果你非得用5V供电可以加一个1kΩ串联电阻再进MCU配合MCU的内部上拉勉强能用但不推荐。5.3 数据偶发校验失败中断正在打断时序这个坑是几乎所有单总线设备的“经典剧目”。DHT11时序里数据位0的高电平只有26~28us数据位1的高电平也只有70us左右。如果此时MCU收到一个优先级较高的中断比如定时器中断、串口中断、CAN中断进入中断服务函数后要花几十微秒才能退出那高电平宽度的测量就会严重失真可能导致0变成1校验和自然也对不上。解决方法有三种在读取DHT11期间关闭可屏蔽中断读完再开。这是最简单粗暴的方法只适用于读取过程短3~5ms、且系统对中断延迟不敏感的场景。把DHT11读取逻辑放在一个高优先级的中断里但要注意嵌套优先级配置避免它被别的中断打断。使用定时器输入捕获功能把电平宽度测量交给硬件完成MCU只读捕获寄存器。这个方法最可靠但代码复杂度上升不少。我的实际做法是在FreeRTOS项目里用临界区保护DHT11的读取函数同时关闭调度器切换确保一段完整读取过程不被任务切换打断。对于周期性的无线发送中断只能选择错开时间或者干脆降低DHT11的读取频率反正它一秒才更新一次把读取时刻安排在系统负荷最低的位置即可。5.4 为什么有时读到“0%RH”或“0°C”但校验通过了这个现象初看很诡异校验和明明是好的但湿度是0温度也是0。排查了一下发现是我在读取时序时数据位解析的阈值设置成了20us结果把所有的1都误判成了0导致四个数据字节全为0校验和也是0校验自然“通过”。所以校验通过不等于数据正确只能说传输过程中没有发生位翻转。如果四字节全为0那只是校验函数的巧合。解决方法是加一层数据合理性判断温度正常范围在0~50°C湿度正常范围在20%RH~90%RH如果读回的数据明显越界即使校验通过也按无效处理。这类问题也提醒我们传感器的数据有效性判断不能只依赖协议自带的校验还要结合行业知识和量程范围做二次过滤。这在工程上是特别值得养成的习惯。5.5 读取间隔太短导致数据不刷新前面提到过DHT11自身的采样周期约1秒。如果你的代码读取间隔只有200ms那么大概率会连续多次读到同一帧数据。有些应用看着“数据没变”就以为是传感器坏了实际上只是没到更新时间。调试时可以这样做先读一次丢弃延时1秒以上再读一次对比两次数据是否不同。如果不同说明传感器在正常更新。在最终项目代码里建议用时间戳控制读取频率比如限定最小读取间隔为1.1秒或1.2秒留出一点余量避免刚好卡在传感器更新的边界上产生临界问题。6. 驱动的一些进阶考虑6.1 如果MCU主频不同延时函数怎么办不同主频下基于SysTick或NOP的延时函数都要重新校准。最稳妥的办法是用一个逻辑分析仪接在GPIO上故意把某个引脚在延时期间拉高实测高电平宽度再调整延时常量。比如你在STM32F10372MHz上调好了一个20us的延时搬到STM32G03064MHz上同样的延时代码实际可能会变成22us或18us虽然DHT11的起始信号允许18~30us的范围但裕量小了总归不放心。校准一遍心里踏实。6.2 从DHT11到DHT22的升级路径如果你觉得DHT11的精度不够DHT22AM2302是一个平滑的升级选择。DHT22同样是单总线协议引脚完全兼容但数据格式里小数位是真实有效的湿度分辨率0.1%RH温度分辨率0.1°C量程也更大。协议时序和数据帧结构基本一致校验方式也一样所以驱动代码只需要微调比如温度负数的处理就能完成升级。DHT22的温度数据在零下时用二进制补码表示最高位为1。写驱动时如果直接按无符号数解析零下温度会变成一个极大的正数这时候要判断最高位是否为1如果是就减去0x10000再除以10。DHT11协议里没有这个问题因为DHT11的温度范围不涉及零下。6.3 单总线的抗干扰设计DHT11的单总线如果走线过长容易受到电磁干扰。实测经验是线长尽量小于50cm越短越好。数据线和电源线、地线挨近走减少环路面积。如果必须走1米以上的延长线在传感器端并联一个0.1uF的陶瓷电容到地吸收高频干扰也可以在数据线上串联一个几十欧姆的电阻抑制振铃。传感器和MCU的电源最好共用一颗100nF10uF组合去耦电容避免供电噪声直接耦合进数据线。我见过有人把DHT11用好几米的网线拉到室外测环境温湿度读回来的数据偶尔出错加了一个瓷片电容后情况明显好转。这种小处理的成本极低但效果立竿见影。6.4 复用单总线驱动做其他器件DHT11的单总线驱动掌握之后做DS18B20温度传感器的驱动会轻松很多因为DS18B20的通信基础同样是“主机拉低起始信号 → 从机响应脉冲 → 按位读写数据”只不过DS18B20还支持总线上的多节点寻址每个器件有唯一的64位ROM码协议更复杂一些。但底层对电平宽度的测量逻辑是相通的你在DHT11驱动里写好的“等待低电平结束→测高电平宽度→判决0/1”这套状态机可以直接迁移过去用。7. 关于DHT11我最后想说的很多人纠结DHT11精度不够、协议老气然后费尽心思去论证SHT30更适合项目这当然没错。但从学习的角度看DHT11恰恰因为它没有硬件协议控制器帮衬反而逼着你把时序、电平、上拉、中断延迟这些基本功全部过了一遍。你把这些东西弄明白了以后看任何器件的驱动代码都会有一种“拆穿魔术”的感觉。之前带过一个实习生做温湿度采集项目时直接拷贝了一份DHT11驱动跑通了就开始写报告。我特意让他把驱动删掉、只看数据手册自己写一遍他写完拿过来给我看我指出了几处阈值和超时处理上的细节他改完之后跟我说“原来之前抄的代码里有很多让我疑惑但没深究的地方现在全通了。”后来他去调其他传感器驱动明显顺手很多。所以这篇拆解的核心目的不是让你把DHT11驱动背下来而是希望你理解每一次电平变化背后的原理知道超时为什么要设、校验为什么能过、阈值为什么取40us。把这些点都串起来单总线通信就不再是一个玄学而是你能掌控的一种工具。真到了项目里如果你用的是STM32F103我上面的代码可以直接编译跑起来如果是其他MCU按第4.4节列出的改动点改一下底层函数就行。遇到任何时序问题别忘了先拿起示波器或逻辑分析仪看波形再决定要不要动代码。数据手册和时序表放在前面遇到问题回去对照基本都能找到原因。