简介围绕MSP430G2553驱动DHT11温湿度传感器并在12864液晶上显示数据的嵌入式实践资料面向学习MSP430单片机、单总线通信与LCD显示的初学者及课程设计者可用于理解低功耗MCU的I/O时序控制、传感器数据解码和校验处理。压缩包共1个doc文件约50KB以项目说明和源码整理为主内容涉及DHT11启动信号、40位脉冲数据接收、温湿度数值显示、微秒与毫秒延时、12864初始化及汉字数字显示等环节。DHT11采用单总线协议主机拉低数据线发起通信传感器返回包含湿度、温度和校验和的40位信息代码通过判断高低电平宽度完成解码并对读取结果进行校验。已有962人学习适合对照代码梳理通信流程、排查时序与校验问题。读者可从中获得一个小型温湿度采集显示项目的代码框架、函数组织方式和调试参考便于迁移到类似的MSP430嵌入式实验。1. 单总线解码40 位温湿度数据是靠“数高电平有多长”数出来的DHT11 只有一根数据线没有时钟线。主机和它之间没有任何同步信号40 位数据的每一位到底是 0 还是 1完全取决于那根线被拉高持续了多久——26 到 28 微秒是 070 微秒左右是 1。这个数字差在 1MHz 的单片机上只相当于四十来个指令周期也就是说解码的本质不是“读传感器”而是“在正确的那一刻看一眼线”。这件事值得搞明白因为所有单总线器件都是这套路数主机拉低启动、从机应答、然后靠电平宽度编码。搞懂一次后面换任何一种单线传感器都只是改时间常数。而搞不懂的代价是——同一份代码换个主频、换个编译器优化等级读数就集体翻车还查不出原因。2. 时序窗口为什么 DHT11 只能靠软件延时去“听”2.1 起始信号主机拉低 18ms 之后主动权就交出去了MSP430G2553 的 P2.1 在空闲时是输入态靠外部上拉电阻维持高电平。要发起一次采集主机必须先把这根线拽下来并且保持足够长的时间——DHT11 的规格是至少 18ms原代码里用的是 20ms。这个 20ms 不是随便写的它的作用是让传感器确认“这是一次真正的主机启动”而不是线上的一阵干扰噪声。void receive_init() { P2DIR | BIT1; // P2.1 切输出准备主动驱动总线 LOW; // 拉低总线 delay_ms(20); // 保持 18msDHT11 才会认这次启动 HIGH; // 释放为高交给从机 delay_us(30); // 保持 20~40us 的高电平作为起始信号的收尾 P2DIR ~BIT1; // 切成输入此后总线由 DHT11 驱动 // ... }拉低 18ms 之后拉高这个高电平只维持 20 到 40 微秒然后立刻把引脚切成输入。切输入这一步是整个流程的分水岭从这一行执行完开始MSP430 就交出了总线控制权它唯一能做的就是被动地看电平变化。常见的实现思路是用P2DIR而不是P2OUT来切换方向这样即使输出寄存器里还留着高电平引脚也已经进入高阻态不会和传感器的输出打架。紧接着 DHT11 会回应先拉低约 80 微秒再拉高约 80 微秒。这两个 80 微秒构成了“应答信号”代码里那两个while循环就是在等它们结束。2.2 每一位的编码50us 低电平打头高电平长度决定 0 还是 1应答结束后DHT11 连续吐出 40 位。每一位的格式是固定的先一段约 50 微秒的低电平作为位分隔然后是高电平——这一段才是数据本身。高电平持续 26 到 28 微秒表示这一位是 0持续 70 微秒表示这一位是 1。这里有个容易被忽略的细节低电平那 50 微秒对每一位都是一样的它不携带任何信息纯粹是节拍器。真正需要在意的只有高电平的宽度。这就解释了receive()里的结构为什么是先等低电平结束、再延时、再判断char receive(void) { unsigned char num 0, tem, cnt; for (cnt 0; cnt 8; cnt) // 一次收 8 位凑成一个字节 { tem 0; while (!(P2IN BIT1)); // 等这一位的 50us 低电平过去 delay_us(30); // 高电平开始后 30us 采样 if (P2IN BIT1) // 30us 时还是高 - 这位是 1 { tem 1; while (P2IN BIT1); // 等 70us 的高电平彻底结束 } num 1; // 高位在前先收的往左顶 num | tem; } return num; }while (!(P2IN BIT1));这一行是个空循环它的唯一作用就是把时间“耗”到低电平结束。循环体是空的但每次判断都要读端口、做位与、跳转在 1MHz 下大约消耗三到五个指令周期也就是三到五微秒的时间粒度。这个粒度对 50 微秒的低电平来说完全够用但如果主频降到 32kHz一个循环就要上百微秒整个协议直接失效。2.3 30us 采样点把连续的“长度”切成离散的“0 和 1”delay_us(30)是整个解码里最关键的一个数字。为什么是 30因为 0 的高电平只有 26 到 28 微秒。等 30 微秒再去看如果这位是 0线早就掉下去了读到低电平如果这位是 1高电平还在持续总长 70 微秒读到高电平。30 微秒正好卡在两种宽度的中间偏下的位置给 0 留了约 2 微秒余量给 1 留了 40 微秒余量。这个采样点的容错是不对称的往小了偏0 和 1 都可能被误判成 1往大了偏比如偏到 60 微秒0 依然能正确识别但余量被压缩。所以如果 DCO 频率偏高delay_us(30)实际变成 33 微秒问题不大如果频率偏低变成 27 微秒就会开始出现把 0 读成 1 的错误——表现为湿度值偶尔跳到 100 多、温度跳到 80 多而且每次刷新数值都不一样。还有个隐藏问题while (P2IN BIT1);在识别出 1 之后要等高电平结束这个循环没有退出条件以外的任何保护。如果传感器没接好、线被拉死在高电平程序会永远卡在这里。真正的产品代码里这种等待必须带超时计数。3. 位拼接与延时精度一个 for 循环里藏着的两个隐患3.1 num 1 的顺序为什么先收到的位要往左顶DHT11 的 40 位是高位先出MSB first。也就是说一个字节里 bit7 最先到达bit0 最后到达。num 1; num | tem;这两行的作用是把已经收到的位不断左移腾出最低位再把新来的一位填进去。收满 8 次之后最先到达的那位正好停在 bit7 上。这个顺序一旦搞反得到的不是“数据错误”而是“位序颠倒”——比如湿度 450x2D二进制 00101101会被读成 0xB4180。这种错误有个特征数值看起来很离谱但校验和居然还能对上因为校验和算的是字节值本身位序反了它也跟着反一样能通过。这是排查时最容易走弯路的地方。40 位按顺序是湿度整数、湿度小数、温度整数、温度小数、校验和。代码里对应humdh、humdl、temph、templ、check五个字节。DHT11 的分辨率是 1℃ 和 1%RH小数部分恒为 0所以原代码把小数位的显示注释掉了只显示整数——这不是偷懒是这颗传感器本来就没有小数。3.2 while 空转的隐含契约它假设对方一定会回应整个receive()和receive_init()里所有的等待都是while (!(P2IN BIT1));这种形式没有计数器没有超时。这套写法能 work 的前提是一个隐含契约DHT11 一定会在规定时间内给出响应并且一定会在规定时间内把线释放掉。这个契约在台面上成立在台面下很脆弱。传感器供电不足、上拉电阻缺失、线太长引入容性负载任何一个都会让响应变慢或者干脆不来。这时候程序不是报错而是静默地卡死在一个空循环里——没有输出、没有提示从外面看就是“屏幕不刷新了”。这种故障形态就是典型的黑匣子你没有日志可看只能靠猜。稳妥的写法是给每个等待加一个上限unsigned int timeout 0; while (!(P2IN BIT1)) { if (timeout 2000) return 0; // 约 10ms 还没等到判定超时 }代价是多几条指令收益是故障从“卡死”变成“返回一个可判断的错误码”。在 1MHz 的机器上这点开销完全值得。3.3 __delay_cycles 与 DCO 校准延时不准一切都是空谈原代码里的延时宏是这么写的#define CPU (1000000) #define delay_us(x) (__delay_cycles((double)x*CPU/1000000.0)) #define delay_ms(x) (__delay_cycles((double)x*CPU/1000.0))__delay_cycles是编译器内建函数它不会生成函数调用而是直接展开成一段按周期计数的空转指令序列参数必须是编译期就能算出来的常量。这意味着delay_us(30)展开成 30 个周期的空转而delay_us(some_variable)要么编译不过要么退化成软件循环——具体行为取决于编译器版本这本身就是个不确定性来源。写这种宏的时候参数必须保证是字面量。更要命的是频率本身。CPU定义为 1000000但真正的 1MHz 从哪来DCOCTL CALDCO_1MHZ; // 只写了 DCO 控制寄存器CALDCO_1MHZ是芯片出厂时写在信息 Flash 里的校准值配套的还有CALBC1_1MHZ要写进BCSCTL1。只写前者不写后者DCO 的实际频率并不是标称的 1MHz而是复位默认的 Basic Clock 配置下叠加了 DCO 校准值的某个频率——通常在 1MHz 附近但偏差可能到百分之十几。百分之十几是什么概念30 微秒变成 34 微秒还在容错范围内但如果反过来变成 26 微秒就开始批量误判了。正确的初始化是两行DCOCTL CALDCO_1MHZ; BCSCTL1 CALBC1_1MHZ; // 这一行漏了DCO 频率就不在标称点上而且即便写全了这个校准值也只是常温常压下的典型值。DCO 是 RC 振荡器天生对温度和电压敏感几个百分点的漂移是正常的。所以靠软件延时解码单总线本质上是在一个不精确的时基上做一个容错窗口很窄的判断——能 work但余量不大。这就是为什么这类代码在实验室里跑得好好的装到设备里到了夏天就开始出问题。血泪经验大多来自这里。4. 校验与显示从字节到屏幕的两道转换4.1 8 位累加校验它能挡住什么挡不住什么DHT11 的第 40 位是校验和算法是前四个字节相加取低 8 位。代码里对应cal humdh humdl temph templ; // cal 是 unsigned char溢出自动截断 if (cal check) { show_Wendu(); show_Shidu(); }cal声明成unsigned char相加结果超过 255 就自然回绕这个截断正是协议要的行为不需要额外处理。校验失败时原代码什么都不做——保留上一次的显示值不刷新。这比显示错误数据要好但用户看不出“数据是旧的”。这个校验能挡住的是传输过程中的位错误某一位因为时序抖动被读反了累加和就对不上。它挡不住的是整体错位比如起始位置判断偏了一位导致后面 40 位全部左移读出来的五个字节完全是另一组数。这种情况下校验通过的概率是 1/256也就是说错位 256 次会有一次“合法”的假数据被显示出来。概率不高但它存在而且这种错误在现象上和真实数据没有区别。设计上的教训是单字节校验和只能当作“传输完整性检查”不能当作“数据可信度保证”。真要做得稳得靠多次采样比对或者检查数值范围湿度不超过 100、温度在合理区间这类范围检查在实际项目里的性价比远高于增加校验位。4.2 ST7920 串行帧一个字节的数据为什么要拆成两个字节发12864 液晶用的控制芯片支持并行和串行两种接法原代码走的是串行只占三个 IO片选、数据、时钟。串行模式的帧格式比较特殊一次完整的传输是三个字节void Send(uchar type, uchar transdata) { uchar firstbyte 0xf8; // 11111 RW(0) RS 0 uchar temp; uchar i, j 3; if (type) firstbyte | 0x02; // type1 表示数据RS 置 1首字节变 0xFA cyPORT | BIT(cyCS); cyPORT ~BIT(cyCLK); while (j 0) { if (j 3) temp firstbyte; // 同步字节 else if (j 2) temp transdata 0xf0; // 高 4 位放字节高位 else temp (transdata 4) 0xf0; // 低 4 位移到高位 for (i 8; i 0; i--) // 逐位串行移出高位在前 { if (temp 0x80) cyPORT | BIT(cySID); else cyPORT ~BIT(cySID); cyPORT | BIT(cyCLK); // 上升沿从机采样 temp 1; cyPORT ~BIT(cyCLK); } if (j 3) delay_Nus(600); // 三个字节之间的间隔短了会丢帧 else delay_Nus(200); j--; } cyPORT ~BIT(cySID); cyPORT ~BIT(cyCS); }这套帧设计的动机很直接串行口只有一根数据线但芯片内部的数据总线是 8 位的所以协议规定“每个字节只传低 4 位有效数据”。高 4 位和低 4 位各占一个传输字节这就是为什么一个 8 位数据要发两次。注意transdata 4之后没有右移回来低 4 位被顶到了字节的高位——这正是芯片要求的格式不是笔误。代码里那两句delay_Nus(600)和delay_Nus(200)是实打实的踩坑产物。三个字节之间必须有足够间隔否则控制芯片来不及处理表现为花屏、乱码或者整块屏不亮。这种问题示波器上看不出明显异常——逻辑是对的只是快了一点但屏就是不认。所以注释里那句“否则易出现时序问题”是有代价换来的。4.3 DDRAM 地址硬编码改一个字符就要重算一遍显示部分靠Disp_HZ和Disp_SZ两个函数往指定地址写数据。地址是硬编码的void show_tishi() { Disp_HZ(0x80, tishi_W, 3); // 温度 3 个汉字 6 字节占 0x80~0x85 Disp_HZ(0x86, tishi_Wd, 1); // ° 占 0x86~0x87 Disp_HZ(0x88, tishi_S, 3); // 湿度 3 个汉字 6 字节占 0x88~0x8d Disp_HZ(0x8e, tishi_Sd, 1); // % 占 0x8e~0x8f }这里有个容易忽略的事实这颗控制芯片的显示 RAM 地址不是线性的。第一行是 0x80 到 0x8F第二行是 0x90 到 0x9F第三行是 0x88 到 0x8F第四行是 0x98 到 0x9F。所以 0x88 并不是“第一行第八个字符”而是第三行的开头。原代码把湿度提示放在 0x88实际是把温度和湿度排在了第一行和第三行中间隔了一行——这是有意利用了地址映射不是随便写的。数值显示的地址同样硬编码Disp_SZ(0x83, temph)把温度值写到 0x83。而 0x83 落在tishi_W占用的 0x80 到 0x85 区间之内也就是说数值会覆盖掉提示文字的一部分。这种重叠在调试阶段很常见改排版的时候必须把每个字符串占多少字节算清楚然后用一个宏把地址算出来而不是见一个填一个。Disp_SZ本身的逻辑很朴素除以 10 取十位对 10 取余取个位然后查numtable转成 ASCII 码写进去。这决定了它只能显示两位整数超过 99 的数会显示错误——对 DHT11 来说够用湿度最大 100温度范围 -20 到 60但如果换成量程更大的传感器这个函数就得重写。5. 排查清单那些让读数一直不变的坑5.1 现象屏幕上的数值一直不动或者开机就是一串固定值原因几乎总是采集间隔不够。DHT11 的两次采集之间需要至少 1 秒的间隔这是传感器内部的转换周期决定的。原代码的主循环是这样的while (1) { receive_init(); // 内部只有 20ms 起始信号 约 4ms 传输 cal humdh humdl temph templ; if (cal check) { show_Wendu(); show_Shidu(); } }整个循环跑一圈不到 25 毫秒也就是说每秒向传感器要了四十几次数据远超它 1 秒一次的更新能力。结果就是读到的可能是上一次的缓存值也可能是传感器在忙时给出的无效响应。解决很简单在循环末尾加一句delay_ms(1000)或者用定时器做 1.5 秒的节拍。这一条是最常翻车的因为它不报错只是数据“看起来对但不变”。5.2 现象校验一直不过或者五个字节全是 0先看硬件。DHT11 的数据线是开漏结构必须有上拉电阻才能回到高电平。裸传感器需要外接 4.7k 到 10k 的上拉带小板子的模块通常已经焊好了。如果上拉缺失主机释放总线之后线会悬空读到的电平随机receive()里所有的while等待都会失效。判断方法很直接把 P2.1 配成输入不接传感器用万用表量这个引脚对地的电压。正常应该接近电源电压如果读到的是 1 点几伏的中间值就是没有上拉或者上拉太弱。另一个常见的接线问题是 VCC 和 DATA 接反这种接法会让传感器完全不响应现象和“上拉缺失”一模一样。5.3 现象只换了个芯片或者换了块板读数就开始乱跳这就是第 3 章说的时钟校准问题。CALDCO_1MHZ和CALBC1_1MHZ是每颗芯片在出厂时单独校准并写进信息 Flash 的不是一个固定常数。如果代码里写的是硬编码的校准值而不是读寄存器换芯片之后频率就会偏。正确的做法是永远用CALDCO_1MHZ/CALBC1_1MHZ这两个符号让编译器从芯片里取值。顺带一个容易被忽略的点DCO 是 RC 振荡器工作在 1MHz 时受温度影响明显。如果设备要在宽温环境里跑靠软件延时解码单总线就需要留更大的容错余量或者干脆改用外部晶振做时基。5.4 现象传感器上电后第一次采集必定失败之后才正常DHT11 上电后需要约 1 秒的稳定时间这段时间内它不接受任何指令。原代码进main()之后直接进while(1)开始采集只靠Ini_Lcd()里那个delay_Nms(100)捱过去——100 毫秒远远不够。这段初始化在显示上是要等 100 毫秒的但传感器需要的是 1000 毫秒两个时间不在一个量级上。解决办法是在Ini_Lcd()之后补一个delay_ms(1000)再进主循环。这个坑的隐蔽之处在于它只影响第一次采集后面的采集会成功所以如果调试时盯着屏幕看几秒很容易误判为“没有问题”。5.5 现象程序烧进去就不动了但单步调试一切正常时序类代码在单步调试下几乎必然是正常的因为调试器把每条指令都拉长了几百倍delay_us(30)变成 30 毫秒采样点偏差再大也还是落在正确区间里。一旦全速运行问题立刻暴露。这类问题只能靠示波器或者逻辑分析仪看波形把 P2.1 上的电平变化抓下来量一下低电平 50 微秒、高电平 26 微秒和 70 微秒这几个特征值到底是多少。如果实测值和理论值系统性偏差 10% 以上说明时钟配置有问题而不是代码逻辑有问题。这个区分很关键因为两者的修改方向完全相反。6. 阻塞式时序的取舍什么时候该把 DHT11 交给定时器捕获这套代码走的路线叫“软件延时 阻塞轮询”用空转消耗时间用空循环等待电平翻转。它的优点是实现极其直接一个for循环八次就能拼出一个字节不需要配置任何外设代码量小到可以塞进任何一颗小容量芯片。代价也很清楚。第一解码期间 CPU 完全被占用。40 位的传输大约要 4 毫秒加上 20 毫秒的起始信号一次采集要占掉 24 毫秒左右的 CPU 时间这段时间里什么都干不了。第二时基不可靠。整个解码依赖 DCO 的精度而 DCO 是 RC 振荡器温漂几个百分点是常态。第三没有任何故障恢复能力线断了就是死循环。如果应用场景是“每隔几秒读一次温湿度屏幕刷新一下”那么这三点代价都不算什么阻塞式是性价比最高的选择。但如果设备还要同时处理按键、串口通信、电机控制24 毫秒的阻塞就是不可接受的。替代路径是用定时器的输入捕获功能。思路是把 P2.1 复用为定时器捕获输入配置成上升沿和下降沿都触发。DHT11 每一次电平跳变都会让定时器把当前计数值锁存到捕获寄存器里中断里读出两次捕获值的差就得到了电平宽度。这样 CPU 只在跳变的那一刻被打断其余时间可以干别的。两种方案的对比大致是这样维度软件延时阻塞式定时器输入捕获时基来源DCO受温漂影响定时器时钟可挂晶振CPU 占用一次采集约 24ms 全阻塞仅在边沿中断时占用几微秒代码复杂度低单文件可完成需要配置定时器、写中断服务程序抗干扰能力弱无超时保护强天然带计数值可判断超时适用场景单一功能、低频采集多任务、高实时性要求但要注意捕获方案也不是没有代价。40 位数据意味着 80 次电平跳变每一次都要进中断中断响应本身的延迟会引入测量误差。对 26 微秒和 70 微秒这种量级的判断来说几微秒的中断延迟还在可接受范围内但如果主频更低或者中断里干了太多事误差就会累积。我一般的取舍标准是看“这个传感器的读数出错会导致什么”。如果只是刷新一块显示屏用户看错了大不了手动再读一次那就用阻塞式代码短、好维护、出问题一眼能看出来。如果读数是拿去闭环控制或者写进存储做记录那就必须换成捕获方案并且给每一个等待加超时。我自己的习惯是不管用哪种方案第一次上板之前一定把 P2.1 的波形抓下来量一遍低电平和高电平的实际长度确认时钟真正的频率是多少。此前有一次我跳过这一步直接烧写结果数据总是差几个数查了两个晚上最后发现是漏写了BCSCTL1那一行频率偏了将近 15%而代码逻辑从头到尾都是对的。从那以后我每次碰单总线器件都强制先量一遍波形再谈调试。希望帮到你。这套工程里的main.c、12864.c、12864.h三个部分合在一起就是一条从时序解码、校验判断到屏幕驱动的完整链路每一环的取舍都写在代码注释里。想把它跑起来或者改成自己的排版直接拿这份源码对着示波器走一遍比看十篇协议说明都快。本文还有配套的精品资源点击获取