嵌入式小白第三站:UART、I2C、SPI、ADC 怎么学?从传感器读数到完整小项目

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构
C语言
嵌入式小白启动!
重要OJ算法题详解
蓝桥杯备战
C++从菜鸟到强手
python启航
AI大模型Agent:拥抱未来,赋能自己

文章目录

  • [嵌入式小白第三站:UART、I2C、SPI、ADC 怎么学?从传感器读数到完整小项目](#嵌入式小白第三站:UART、I2C、SPI、ADC 怎么学?从传感器读数到完整小项目)
    • 1) 先建立一个大局观:外设通信不是背协议,是搭数据管道 先建立一个大局观:外设通信不是背协议,是搭数据管道)
    • 2) UART:最朴素、最常用、最值得先学的通信方式 UART:最朴素、最常用、最值得先学的通信方式)
      • [2.1 UART 的核心概念](#2.1 UART 的核心概念)
      • [2.2 TTL 串口、RS232、RS485 不是一回事](#2.2 TTL 串口、RS232、RS485 不是一回事)
      • [2.3 为什么串口会乱码?](#2.3 为什么串口会乱码?)
      • [2.4 串口接收:别让数据把你冲晕](#2.4 串口接收:别让数据把你冲晕)
    • 3) I2C:两根线挂一串设备,简单外表下很讲规矩 I2C:两根线挂一串设备,简单外表下很讲规矩)
      • [3.1 I2C 的几个核心动作](#3.1 I2C 的几个核心动作)
      • [3.2 7 位地址和 8 位地址:I2C 新手大坑](#3.2 7 位地址和 8 位地址:I2C 新手大坑)
      • [3.3 I2C 为什么必须上拉?](#3.3 I2C 为什么必须上拉?)
      • [3.4 I2C 自救清单](#3.4 I2C 自救清单)
    • 4) SPI:线多一点,速度快很多,屏幕和 Flash 很爱它 SPI:线多一点,速度快很多,屏幕和 Flash 很爱它)
      • [4.1 CS 片选:你先点名,它才说话](#4.1 CS 片选:你先点名,它才说话)
      • [4.2 CPOL/CPHA:SPI 的四种模式](#4.2 CPOL/CPHA:SPI 的四种模式)
      • [4.3 SPI 读写不总是"发什么就收什么"](#4.3 SPI 读写不总是“发什么就收什么”)
    • 5) ADC:把模拟世界切成数字格子 ADC:把模拟世界切成数字格子)
      • [5.1 分辨率:12 位 ADC 到底代表什么?](#5.1 分辨率:12 位 ADC 到底代表什么?)
      • [5.2 采样时间和输入阻抗:为什么 ADC 值会飘?](#5.2 采样时间和输入阻抗:为什么 ADC 值会飘?)
      • [5.3 软件滤波不是补锅神器](#5.3 软件滤波不是补锅神器)
      • [5.4 电池电压检测:为什么要分压?](#5.4 电池电压检测:为什么要分压?)
    • 6) 定时器、中断、DMA:让系统从"排队干活"升级为"有节奏地协作" 定时器、中断、DMA:让系统从“排队干活”升级为“有节奏地协作”)
      • [6.1 定时器:给系统一个节拍器](#6.1 定时器:给系统一个节拍器)
      • [6.2 中断:处理突发事件,但不要在里面安家](#6.2 中断:处理突发事件,但不要在里面安家)
      • [6.3 DMA:让外设自己搬数据](#6.3 DMA:让外设自己搬数据)
    • 7) 调试工具:不要只靠眼神和感觉 调试工具:不要只靠眼神和感觉)
      • [7.1 串口日志:最便宜的系统旁白](#7.1 串口日志:最便宜的系统旁白)
      • [7.2 逻辑分析仪:通信协议的照妖镜](#7.2 逻辑分析仪:通信协议的照妖镜)
      • [7.3 示波器:看模拟、电源和边沿](#7.3 示波器:看模拟、电源和边沿)
      • [7.4 SWD/JTAG:进 MCU 里面看看](#7.4 SWD/JTAG:进 MCU 里面看看)
    • 8) 把知识串成项目:桌面环境监测小站 把知识串成项目:桌面环境监测小站)
      • [8.1 项目功能](#8.1 项目功能)
      • [8.2 模块分层](#8.2 模块分层)
      • [8.3 主循环框架](#8.3 主循环框架)
      • [8.4 串口命令设计](#8.4 串口命令设计)
      • [8.5 I2C 传感器驱动思路](#8.5 I2C 传感器驱动思路)
      • [8.6 OLED 显示:别一秒刷一万次](#8.6 OLED 显示:别一秒刷一万次)
      • [8.7 报警逻辑:用状态机,不要用一堆 if 硬堆](#8.7 报警逻辑:用状态机,不要用一堆 if 硬堆)
    • 9) 常见协议问题对照表 常见协议问题对照表)
    • 10) 新手学习顺序:从能看见结果到能解释原因 新手学习顺序:从能看见结果到能解释原因)
    • 11) 本篇最终总结:通信协议是嵌入式项目的血管 本篇最终总结:通信协议是嵌入式项目的血管)

嵌入式小白第三站:UART、I2C、SPI、ADC 怎么学?从传感器读数到完整小项目

!在这里插入图片描述(https://i-blog.csdnimg.cn/direct/8595580a8ee![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/59bacd646bb54f09b0389bb62cc8f67d.png)

f467ea6391d6824038c8a.png)

如果第二篇的关键词是"控制一根引脚",那第三篇的关键词就是"组织一条数据流"。

点亮 LED、读取按键之后,你已经能让 MCU 对外部世界做出最简单的反应了。但真正的嵌入式项目不会只停在"灯亮不亮、按键按没按"。它通常要做这些事:

  • 从温湿度、姿态、电流、电压、距离等传感器读取数据;
  • 把数据显示到 OLED、LCD 或上位机;
  • 通过串口接收命令;
  • 通过 Wi-Fi、蓝牙、CAN、RS485 等方式和其他设备通信;
  • 按固定周期采样;
  • 在异常时报警;
  • 在资源有限的 MCU 上让多个任务同时看起来"井井有条"。

你会发现,嵌入式项目的难点开始从"某一行代码怎么写"变成"数据怎么来、怎么走、怎么处理、怎么证明它没错"。

这一篇我们就讲新手必须跨过去的几座桥:UART、I2C、SPI、ADC、定时器、中断、DMA、调试工具,以及如何把它们串成一个真正能跑的小项目。

1) 先建立一个大局观:外设通信不是背协议,是搭数据管道

很多人学通信协议时会陷入一种痛苦:UART 有波特率,I2C 有地址,SPI 有 CPOL/CPHA,ADC 有采样时间,定时器还有分频和溢出。每个词都像认识,但凑一起就开始头疼。

我们换个角度。

一个嵌入式系统里的数据通常会经历这样的路径:
#mermaid-svg-EHnREmHVYHApNnmU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EHnREmHVYHApNnmU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EHnREmHVYHApNnmU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EHnREmHVYHApNnmU .error-icon{fill:#552222;}#mermaid-svg-EHnREmHVYHApNnmU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EHnREmHVYHApNnmU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EHnREmHVYHApNnmU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EHnREmHVYHApNnmU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EHnREmHVYHApNnmU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EHnREmHVYHApNnmU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EHnREmHVYHApNnmU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EHnREmHVYHApNnmU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EHnREmHVYHApNnmU .marker.cross{stroke:#333333;}#mermaid-svg-EHnREmHVYHApNnmU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EHnREmHVYHApNnmU p{margin:0;}#mermaid-svg-EHnREmHVYHApNnmU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EHnREmHVYHApNnmU .cluster-label text{fill:#333;}#mermaid-svg-EHnREmHVYHApNnmU .cluster-label span{color:#333;}#mermaid-svg-EHnREmHVYHApNnmU .cluster-label span p{background-color:transparent;}#mermaid-svg-EHnREmHVYHApNnmU .label text,#mermaid-svg-EHnREmHVYHApNnmU span{fill:#333;color:#333;}#mermaid-svg-EHnREmHVYHApNnmU .node rect,#mermaid-svg-EHnREmHVYHApNnmU .node circle,#mermaid-svg-EHnREmHVYHApNnmU .node ellipse,#mermaid-svg-EHnREmHVYHApNnmU .node polygon,#mermaid-svg-EHnREmHVYHApNnmU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EHnREmHVYHApNnmU .rough-node .label text,#mermaid-svg-EHnREmHVYHApNnmU .node .label text,#mermaid-svg-EHnREmHVYHApNnmU .image-shape .label,#mermaid-svg-EHnREmHVYHApNnmU .icon-shape .label{text-anchor:middle;}#mermaid-svg-EHnREmHVYHApNnmU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EHnREmHVYHApNnmU .rough-node .label,#mermaid-svg-EHnREmHVYHApNnmU .node .label,#mermaid-svg-EHnREmHVYHApNnmU .image-shape .label,#mermaid-svg-EHnREmHVYHApNnmU .icon-shape .label{text-align:center;}#mermaid-svg-EHnREmHVYHApNnmU .node.clickable{cursor:pointer;}#mermaid-svg-EHnREmHVYHApNnmU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EHnREmHVYHApNnmU .arrowheadPath{fill:#333333;}#mermaid-svg-EHnREmHVYHApNnmU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EHnREmHVYHApNnmU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EHnREmHVYHApNnmU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EHnREmHVYHApNnmU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EHnREmHVYHApNnmU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EHnREmHVYHApNnmU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EHnREmHVYHApNnmU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EHnREmHVYHApNnmU .cluster text{fill:#333;}#mermaid-svg-EHnREmHVYHApNnmU .cluster span{color:#333;}#mermaid-svg-EHnREmHVYHApNnmU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EHnREmHVYHApNnmU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EHnREmHVYHApNnmU rect.text{fill:none;stroke-width:0;}#mermaid-svg-EHnREmHVYHApNnmU .icon-shape,#mermaid-svg-EHnREmHVYHApNnmU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EHnREmHVYHApNnmU .icon-shape p,#mermaid-svg-EHnREmHVYHApNnmU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EHnREmHVYHApNnmU .icon-shape .label rect,#mermaid-svg-EHnREmHVYHApNnmU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EHnREmHVYHApNnmU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EHnREmHVYHApNnmU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EHnREmHVYHApNnmU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 真实世界: 温度/电压/动作
传感器或输入电路
MCU 外设: UART/I2C/SPI/ADC
驱动层: 读写寄存器和数据转换
应用层: 状态机/算法/判断
输出: 屏幕/电机/报警/联网

你学 UART、I2C、SPI、ADC,并不是为了背名词,而是为了回答四个问题:

  1. 数据从哪里来?
  2. 数据以什么电气方式传过来?
  3. MCU 哪个外设负责接收它?
  4. 程序如何把原始数据变成有意义的信息?

比如温湿度小系统:

  • SHT30 传感器通过 I2C 输出温湿度原始数据;
  • MCU 通过 I2C 外设读取传感器寄存器;
  • 驱动层把原始字节换算成摄氏度和百分比;
  • 应用层判断是否超温;
  • OLED 显示结果;
  • UART 打印日志或接收命令;
  • 蜂鸣器或 LED 负责报警。

这就是数据流。协议只是数据流中某一段路的交通规则。

【干货】学嵌入式通信时,不要只问"这个协议怎么配"。要问"这段数据从哪来、到哪去、用什么证据证明它到了"。

2) UART:最朴素、最常用、最值得先学的通信方式

UART 是新手最应该优先掌握的通信方式。原因很简单:它既能做设备通信,又是调试神器。

你写的第一句嵌入式日志,大概率是从 UART 出来的:

c 复制代码
printf("system init ok\r\n");

当屏幕还没亮、传感器还没通、网络还没连上时,串口日志就像系统给你递出来的一张小纸条:我跑到这里了,我读到这个值,我刚才出错了。

2.1 UART 的核心概念

UART 常见连接至少需要三根线:

  • TX:发送;
  • RX:接收;
  • GND:共同参考地。

两个设备连接时,一般是交叉接:

  • A 的 TX 接 B 的 RX;
  • A 的 RX 接 B 的 TX;
  • GND 接 GND。

如果 TX 接 TX、RX 接 RX,两个设备就像两个人都对着麦克风讲话,但没人把耳朵靠过去听。

UART 是异步通信,没有单独时钟线。双方靠约定好的波特率和帧格式来对齐。常见配置是 115200 8N1

  • 115200:每秒大约传 115200 个符号位;
  • 8:8 个数据位;
  • N:无校验;
  • 1:1 个停止位。

只要双方波特率、数据位、校验位、停止位不一致,就可能乱码。

2.2 TTL 串口、RS232、RS485 不是一回事

新手还容易把"串口"这两个字混着用。

MCU 引脚上的 UART 通常是 TTL/CMOS 电平,比如 3.3V 或 5V。电脑老式 DB9 那种 RS232 电平范围不同,不能直接硬接 MCU。工业现场常见 RS485,本质上也不是 UART 本身,而是用差分电气层承载串口数据,抗干扰更强、距离更远、可多点总线。

所以你看到"串口模块"时要问:

  • 它是 USB-TTL?
  • 还是 USB-RS232?
  • 还是 USB-RS485?
  • 电平是 3.3V 还是 5V?

【干货】UART 是通信外设/数据格式,TTL/RS232/RS485 是电气层。名字都叫串口,但线不能乱接。

2.3 为什么串口会乱码?

串口乱码是新手最常见的心理测试。常见原因有:

  • PC 端波特率设置和 MCU 不一致;
  • MCU 系统时钟配置错,导致实际波特率偏了;
  • 数据位、校验位、停止位不一致;
  • TX/RX 接反或没共地;
  • 用 5V USB-TTL 接了只能承受 3.3V 的芯片;
  • 打印二进制数据,却用文本方式查看。

排查顺序建议:

  1. 固定使用 115200 8N19600 8N1
  2. 确认系统时钟配置;
  3. TX/RX 交叉接,GND 共地;
  4. 用逻辑分析仪抓 UART,直接解码看波特率和字节;
  5. 先发固定字符串,比如 ABC\r\n,不要一上来发复杂结构体。

2.4 串口接收:别让数据把你冲晕

发送日志很简单,接收命令就会复杂一点。因为数据不是你想什么时候来就什么时候来,它可能半包到、粘包到、带噪声到。

新手常见错误是:在主循环里阻塞等待串口接收。这样做会让系统像坐在门口等快递,快递不来就什么事也不干。

更好的思路:

  • 串口中断或 DMA 接收字节;
  • 放入环形缓冲区;
  • 主循环从缓冲区取数据;
  • 按协议解析完整命令。

一个简单的文本协议可以这样设计:

text 复制代码
LED ON\r\n
LED OFF\r\n
TEMP?\r\n
MODE AUTO\r\n

更工程化的二进制协议可能包含:

  • 帧头;
  • 长度;
  • 命令字;
  • 数据;
  • 校验;
  • 帧尾。

【干货】UART 入门先用文本协议建立信心;项目变复杂后,再考虑二进制帧和 CRC。

3) I2C:两根线挂一串设备,简单外表下很讲规矩

I2C 常用于连接低速传感器、EEPROM、RTC、OLED、小型 ADC/DAC 等器件。它的标志性特点是两根线:

  • SDA:数据线;
  • SCL:时钟线。

再加上电源和地,一个 I2C 模块通常四根线就能工作。

这也是 I2C 很适合新手做传感器项目的原因:线少,模块多,资料多。

3.1 I2C 的几个核心动作

I2C 通信里有几个关键词:

  • Start:起始条件;
  • Stop:停止条件;
  • Address:设备地址;
  • R/W:读写方向;
  • ACK/NACK:应答/不应答;
  • Register Address:很多传感器内部寄存器地址;
  • Data:读写的数据字节。

一个典型的 I2C 读寄存器过程可以理解成:

  1. 主机发起 Start;
  2. 主机发送设备地址 + 写方向;
  3. 从机 ACK;
  4. 主机发送要读取的寄存器地址;
  5. 从机 ACK;
  6. 主机再次 Start;
  7. 主机发送设备地址 + 读方向;
  8. 从机 ACK;
  9. 主机读取数据;
  10. 主机发送 NACK 并 Stop。

你可以把它想成去柜台取资料:

  • 先告诉柜台"我要找 0x44 号窗口";
  • 再告诉它"我要读 0x00 号文件夹";
  • 然后切换成读取模式;
  • 对方把文件夹里的内容递给你。

3.2 7 位地址和 8 位地址:I2C 新手大坑

I2C 地址常见有 7 位表示法。比如某传感器地址是 0x68。但有些资料会把读写位也算进去,写成:

  • 写地址:0xD0
  • 读地址:0xD1

它们其实对应同一个 7 位地址 0x68,因为 0x68 << 1 = 0xD0,最低位再表示读写。

不同库函数要求不同。有的函数要你传 7 位地址,有的函数要你传左移后的 8 位地址。传错后,现象通常是一直 NACK。

【干货】I2C 读不到设备时,第一件事不是改代码逻辑,而是确认库函数要的是 7 位地址还是 8 位地址。

3.3 I2C 为什么必须上拉?

I2C 总线通常采用开漏/开集结构。设备可以把线拉低,但不会主动强推高电平。高电平依靠上拉电阻。

这带来两个好处:

  • 多个设备可以安全共享总线;
  • 任何设备拉低都能被总线看到。

但也带来一个要求:没有上拉,线就高不起来;上拉太弱,速度快时上升沿太慢;线太长、设备太多、电容太大,也会让波形变差。

常见模块板上已经带了 4.7k 或 10k 上拉电阻,但不是所有模块都有。多个模块都带上拉时,等效阻值会变小,也要注意。

3.4 I2C 自救清单

当 I2C 设备读不到时,按这个顺序查:

  1. 电源电压是否正确;
  2. GND 是否共地;
  3. SDA/SCL 有没有接反;
  4. SDA/SCL 是否有上拉;
  5. 地址是 7 位还是 8 位;
  6. 设备地址引脚是否改变了默认地址;
  7. 总线速度是否太快,先降到 100kHz;
  8. 有没有别的设备占用相同地址;
  9. 用 I2C scanner 扫描总线;
  10. 用逻辑分析仪看 Start、地址、ACK/NACK。

如果逻辑分析仪显示根本没有波形,先查 MCU 配置和引脚复用。如果有地址但 NACK,重点查地址、电源、上拉和设备状态。

4) SPI:线多一点,速度快很多,屏幕和 Flash 很爱它

SPI 常用于 OLED/LCD 屏幕、Flash 存储器、无线模块、高速 ADC、某些传感器。它通常有四类线:

  • SCLK:时钟;
  • MOSI:主机输出、从机输入;
  • MISO:主机输入、从机输出;
  • CS:片选。

SPI 的特点是主机提供时钟,从机按时钟收发数据。它通常比 I2C 快,结构也更直接,但线更多。

4.1 CS 片选:你先点名,它才说话

SPI 可以挂多个从设备,但每个从设备通常要有自己的 CS。主机要和某个设备通信时,先把它的 CS 拉到有效电平,通信结束再释放。

如果 CS 没拉对,设备可能完全不理你。屏幕白屏、Flash 读 ID 全 0xFF 或 0x00,很可能就和 CS、复位脚、模式、时序有关。

4.2 CPOL/CPHA:SPI 的四种模式

SPI 有四种常见模式,由 CPOL 和 CPHA 组合决定:

  • CPOL:时钟空闲时是低还是高;
  • CPHA:在第几个时钟边沿采样数据。

模式不对时,波形看起来有,数据却全错。这种错误非常迷惑,因为你会觉得"线在动啊,为什么读不到?"

答案是:对方在上升沿放数据,你在下降沿读;或者对方刚准备好,你已经读完了。

【干货】SPI 调不通时,第一步把速度降下来,第二步确认 mode,第三步用逻辑分析仪看数据边沿。

4.3 SPI 读写不总是"发什么就收什么"

SPI 是同步全双工,主机发送的同时也在接收。但很多设备协议会规定:

  • 先发命令;
  • 再发地址;
  • 可能有 dummy cycles;
  • 再读数据。

比如读 Flash ID,你可能需要先发一个读 ID 命令,然后继续发送空字节来产生时钟,设备才会把 ID 从 MISO 线上吐出来。

新手常犯的错是:只调用一次 receive,却没有提供时钟。SPI 从机不会自己说话,主机不给时钟,它就没有节奏输出数据。

5) ADC:把模拟世界切成数字格子

GPIO 只能读高低电平,但真实世界很多量不是非黑即白的:

  • 电池电压从 4.2V 慢慢掉到 3.7V;
  • 电位器输出 0 到 3.3V 的连续电压;
  • 光敏电阻随光照变化;
  • 麦克风、压力传感器、电流采样都可能是模拟信号。

这时就需要 ADC,把模拟电压转换成数字。

5.1 分辨率:12 位 ADC 到底代表什么?

如果一个 ADC 是 12 位,它能把输入范围分成 2^12 = 4096 个等级,结果通常是 0 到 4095。

如果参考电压是 3.3V,那么每一格大约是:

text 复制代码
3.3V / 4096 ≈ 0.000805V

也就是约 0.805mV。

如果 ADC 读数是 2048,理想情况下对应电压约:

text 复制代码
2048 / 4095 * 3.3V ≈ 1.65V

注意这是理想计算。实际系统里还有参考电压精度、噪声、输入阻抗、采样时间、PCB 布局、传感器误差等因素。

5.2 采样时间和输入阻抗:为什么 ADC 值会飘?

MCU 内部 ADC 采样时,通常会通过一个采样电容短暂接到外部输入。如果外部信号源阻抗太大,采样时间又太短,电容还没充到真实电压,ADC 就开始转换,结果当然不准。

所以 ADC 读数飘时,不要只想着软件滤波。先问:

  • 信号源阻抗高不高?
  • 采样时间够不够?
  • 参考电压稳不稳?
  • 模拟地和数字地处理是否合理?
  • 线是否太长、旁边是否有 PWM/电机干扰?
  • 有没有合适的 RC 滤波?

5.3 软件滤波不是补锅神器

常见滤波方法有:

  • 多次采样取平均;
  • 去掉最大最小后平均;
  • 滑动平均;
  • 一阶低通滤波;
  • 中值滤波。

它们能降低随机噪声,但不能修复接线错误、参考电压乱飘、采样时间严重不足这类根因。

【干货】ADC 调试顺序:先让硬件信号稳定,再用软件滤波变漂亮。不要反过来。

5.4 电池电压检测:为什么要分压?

很多 MCU ADC 不能直接测超过参考电压的输入。比如 MCU 工作在 3.3V,你想测一节锂电池满电 4.2V,就不能直接把电池正极接 ADC。

常见做法是电阻分压:

text 复制代码
电池正极 -- R1 -- ADC点 -- R2 -- GND

ADC 点电压为:

text 复制代码
Vadc = Vbat * R2 / (R1 + R2)

然后软件再反推:

text 复制代码
Vbat = Vadc * (R1 + R2) / R2

这里还要考虑分压电阻耗电、ADC 输入阻抗、采样时间和校准。低功耗项目里,电池分压还可能通过 MOS 管控制,只在测量时打开。

6) 定时器、中断、DMA:让系统从"排队干活"升级为"有节奏地协作"

你可以用 delay 写出很多入门实验,但真正的项目不能只靠 delay。

假设你要做一个温湿度显示系统:

  • 每 1 秒读一次温湿度;
  • 每 200ms 刷新一次按键;
  • 每 50ms 更新一次 LED 状态;
  • 串口随时可能收到命令;
  • OLED 需要定期刷新;
  • 超温时蜂鸣器要按节奏响。

如果你到处写 delay,系统就会像一条单车道:前面有人慢吞吞,后面全部堵死。

6.1 定时器:给系统一个节拍器

定时器可以产生周期中断,也可以输出 PWM、测量频率、捕获脉冲。入门阶段先把它当"系统节拍器"。

比如每 1ms 产生一次 tick,然后任务用时间戳判断是否该执行:

c 复制代码
void app_loop(void)
{
    uint32_t now = millis();

    if (now - last_key_scan >= 5) {
        last_key_scan = now;
        key_scan();
    }

    if (now - last_sensor_read >= 1000) {
        last_sensor_read = now;
        sensor_read_request();
    }

    if (now - last_led_update >= 50) {
        last_led_update = now;
        led_update();
    }
}

这种写法的本质是非阻塞调度。它不要求你立刻学 RTOS,但已经在培养 RTOS 思维。

6.2 中断:处理突发事件,但不要在里面安家

中断适合处理"不能错过"的事件:

  • 串口收到字节;
  • 定时器到点;
  • 外部引脚边沿触发;
  • ADC 转换完成;
  • DMA 传输完成。

中断函数里要遵守几条纪律:

  • 尽量短;
  • 不要 delay;
  • 不要做大段计算;
  • 尽量不要 printf;
  • 只置标志、搬少量数据、清中断标志;
  • 复杂逻辑交给主循环或任务。

【干货】ISR 不是办公室,是前台。它负责接电话、记信息、叫人处理,不负责把整个项目做完。

6.3 DMA:让外设自己搬数据

DMA 可以在外设和内存之间搬运数据,减少 CPU 参与。比如:

  • UART DMA 接收一大段数据;
  • SPI DMA 刷屏;
  • ADC DMA 连续采样;
  • I2C/SPI DMA 读传感器数据。

新手不必一上来就用 DMA,但你要知道它解决的问题:当数据量变大、频率变高、CPU 忙不过来时,让硬件搬运比 CPU 一字节一字节搬更稳定。

学习顺序建议:

  1. 先轮询跑通;
  2. 再中断优化响应;
  3. 最后 DMA 优化吞吐。

别第一天就把 DMA、中断、缓存、半传输回调全搅在一起。那不是学习,是给自己开困难模式。

7) 调试工具:不要只靠眼神和感觉

嵌入式调试最痛苦的地方是:很多错误发生在你看不见的电信号里。

所以工具很重要。

7.1 串口日志:最便宜的系统旁白

串口日志适合回答:

  • 程序有没有跑到这里;
  • 变量当前是多少;
  • 状态机切到哪一步;
  • 传感器返回了什么;
  • 错误码是什么。

但注意,串口打印也会占时间。不要在高频中断里疯狂 printf,也不要在实时性强的地方输出长日志。

7.2 逻辑分析仪:通信协议的照妖镜

逻辑分析仪可以抓数字波形,并解码 UART、I2C、SPI 等协议。它能回答:

  • 线上有没有波形;
  • 波特率是否正确;
  • I2C 地址有没有 ACK;
  • SPI 模式是否像预期;
  • CS 时序有没有拉对;
  • 数据有没有粘包或丢包。

几十块到几百块的逻辑分析仪,对新手来说非常值。它能把"我觉得发了"变成"线上确实发了 0x55"。

7.3 示波器:看模拟、电源和边沿

示波器适合看:

  • PWM 波形;
  • 电源纹波;
  • 按键抖动;
  • ADC 输入;
  • I2C/SPI 边沿质量;
  • 电机启动时电压下跌;
  • 复位脚是否被干扰。

逻辑分析仪告诉你 0 和 1,示波器告诉你这个 0 和 1 背后的电压到底长什么样。

7.4 SWD/JTAG:进 MCU 里面看看

SWD/JTAG 调试器可以让你:

  • 打断点;
  • 单步执行;
  • 查看变量;
  • 查看寄存器;
  • 看调用栈;
  • 观察内存;
  • 定位 HardFault。

新手不要害怕断点调试。串口日志适合看流程,断点调试适合看瞬间状态。两个搭配使用,效率会高很多。

【调试四证据法】一个外设调不通时,尽量拿到四类证据:

  • 代码证据:程序执行到了正确位置;
  • 寄存器证据:外设配置和状态正确;
  • 波形证据:线上真的有符合协议的信号;
  • 物理证据:电源、地线、电平、连接都正确。

只有代码证据是不够的。嵌入式 bug 很多时候藏在另外三类证据里。

8) 把知识串成项目:桌面环境监测小站

现在我们设计一个适合新手进阶的小项目:桌面环境监测小站。

目标不是做一个商业产品,而是在一个项目里把核心外设串起来。

8.1 项目功能

硬件可以这样选:

  • MCU:STM32、ESP32 或其他你熟悉的开发板;
  • 温湿度传感器:SHT30、AHT20、BME280 等 I2C 传感器;
  • OLED:I2C 或 SPI 接口;
  • 一个按键;
  • 一个 LED;
  • 一个蜂鸣器;
  • 一个电位器或电池分压输入,练 ADC;
  • USB-TTL 串口调试。

功能:

  • 每 1 秒读取温湿度;
  • OLED 显示温度、湿度、运行状态;
  • 串口输出日志;
  • 串口输入命令设置报警阈值;
  • 按键切换显示页面;
  • 温度超过阈值时 LED 闪烁、蜂鸣器报警;
  • ADC 读取电位器,用来模拟阈值调节或电池电压;
  • 系统不使用长时间阻塞 delay。

8.2 模块分层

建议文件结构:

text 复制代码
app/
  app_main.c
  app_state.c
drivers/
  bsp_led.c
  bsp_key.c
  bsp_oled.c
  sensor_sht30.c
  uart_protocol.c
  adc_voltage.c
platform/
  board.c
  board.h

不要把所有代码都塞进 main.cmain.c 只负责初始化和调度,具体硬件动作放驱动层,业务逻辑放应用层。

8.3 主循环框架

主循环可以这样组织:

c 复制代码
int main(void)
{
    board_init();
    app_init();

    while (1)
    {
        uint32_t now = millis();

        key_task(now);
        sensor_task(now);
        display_task(now);
        alarm_task(now);
        uart_protocol_task(now);
    }
}

每个 task 都自己判断时间到了没有,不到了就立刻返回。这样系统不会被某一个模块拖住。

8.4 串口命令设计

先用文本命令,简单好调:

text 复制代码
TEMP?
HUMI?
THR 30.5
LED ON
LED OFF
BEEP OFF
STATUS?

返回可以这样:

text 复制代码
OK TEMP=26.4
OK HUMI=52.1
OK THR=30.5
ERR CMD

这个小协议能练到命令解析、字符串处理、状态反馈。等你以后做更复杂的设备,再升级成二进制帧、长度字段和 CRC。

8.5 I2C 传感器驱动思路

以常见 I2C 传感器为例,驱动层要做几件事:

  • 初始化传感器;
  • 发送测量命令;
  • 等待转换完成;
  • 读取原始数据;
  • 校验数据;
  • 换算成实际单位;
  • 返回错误码。

应用层不应该关心 I2C 具体怎么读写,它只需要调用:

c 复制代码
sensor_data_t data;
if (sensor_read(&data) == SENSOR_OK) {
    app_update_environment(data.temperature, data.humidity);
}

这就是分层的好处:底层协议细节变化时,应用层不至于被牵着重写。

8.6 OLED 显示:别一秒刷一万次

OLED 刷新也要有节奏。温湿度 1 秒更新一次,显示 5 到 10 次每秒已经很顺眼了。没必要主循环跑多快就刷多快。

显示层建议维护一个"当前页面":

  • 页面 1:温湿度;
  • 页面 2:阈值和报警状态;
  • 页面 3:ADC 电压和系统运行时间;
  • 页面 4:调试信息。

按键短按切换页面,长按复位统计数据或静音报警。

8.7 报警逻辑:用状态机,不要用一堆 if 硬堆

报警可以设计成几个状态:

  • NORMAL:正常;
  • WARNING:接近阈值;
  • ALARM:超过阈值;
  • SILENCED:用户临时静音;
  • SENSOR_ERROR:传感器错误。

状态机的好处是:你能清楚地定义从一个状态到另一个状态的条件。

例如:

  • 温度超过阈值 3 次连续采样,进入 ALARM;
  • 用户长按按键,进入 SILENCED;
  • 静音 60 秒后,如果温度仍超限,重新进入 ALARM;
  • 传感器连续读取失败,进入 SENSOR_ERROR;
  • 温度恢复到阈值以下并留有回差,回到 NORMAL。

这里提到"回差"很重要。比如阈值 30 度,如果刚到 30.0 就报警,降到 29.9 就解除,温度在 29.9 和 30.1 之间抖动时,报警会来回跳。可以设置:

  • 30.0 度以上报警;
  • 28.5 度以下解除。

这就是回差,能让系统更稳定。

9) 常见协议问题对照表

协议/模块 现象 常见原因 排查动作
UART 乱码 波特率/时钟/帧格式不一致 固定 115200 8N1,抓逻辑分析仪
UART 收不到 TX/RX 没交叉、没共地 交叉接线,测 TX 是否有波形
UART 收半包 接收方式阻塞或无缓冲 中断/DMA + 环形缓冲
I2C 一直 NACK 地址错、没上拉、没供电 扫描地址,查 7/8 位,测 SDA/SCL
I2C 偶尔失败 速度过快、线长、干扰 降到 100kHz,缩短线,加合适上拉
SPI 读 ID 全 0xFF MISO 上拉、从机没响应 查 CS、模式、复位脚、电源
SPI 屏幕花屏 CPOL/CPHA 或初始化序列错 对 datasheet,降速抓波形
ADC 数值乱跳 参考电压/输入阻抗/采样时间 延长采样,加 RC,检查 Vref
ADC 数值比例不对 分压系数或参考电压算错 用万用表测输入点,重新标定
PWM 舵机乱动 频率/脉宽不对或供电不足 确认 50Hz 和脉宽,独立供电共地

这张表的价值不在于背,而在于提醒你:每个协议都有自己的"物理层脾气"。调试时要尊重它。

10) 新手学习顺序:从能看见结果到能解释原因

建议你按这个顺序练:

  1. UART 打印日志。先让 MCU 能和电脑说话。
  2. UART 接收命令。练输入、缓冲、协议解析。
  3. I2C 读一个传感器。练地址、ACK/NACK、寄存器读写。
  4. I2C 或 SPI 驱动 OLED。练显示和数据组织。
  5. ADC 读电位器。练模拟量换算、滤波、标定。
  6. 定时器非阻塞调度。练系统节奏。
  7. 中断接收串口或按键。练事件处理。
  8. 逻辑分析仪抓 UART/I2C/SPI。练证据链。
  9. 把所有模块整合成一个小项目。
  10. 最后再考虑 DMA、RTOS、低功耗和更复杂协议。

每一步都要有可见成果。不要只看教程,不上手验证。嵌入式知识如果没有落到电路和波形上,很容易看起来懂了,做起来全忘。

【干货】每学一个外设,都用这五问验收:

  • 我知道它用哪些引脚吗?
  • 我知道它的电气要求吗?
  • 我知道初始化参数为什么这样配吗?
  • 我能用工具看到它的信号吗?
  • 我能把它封装成一个稳定的驱动接口吗?

五问都能答上来,这个外设才算真的入门。

11) 本篇最终总结:通信协议是嵌入式项目的血管

读到这里,你应该已经建立起这样的认识:

  • UART 是入门最重要的通信方式,也是调试第一工具;
  • I2C 适合连接低速多设备传感器,但要注意地址、ACK 和上拉;
  • SPI 适合高速设备,重点关注 CS、模式、时钟和读写时序;
  • ADC 把模拟电压变成数字,精度不仅取决于位数,还取决于参考电压、采样时间和硬件设计;
  • 定时器让系统有节奏,中断处理突发,DMA 优化大量数据搬运;
  • 调试不能只靠代码,要结合串口、逻辑分析仪、示波器、SWD/JTAG;
  • 小项目要分层,主循环要非阻塞,业务逻辑最好用状态机。

一句话总结:

嵌入式通信不是把协议背熟,而是让数据从真实世界可靠地走到程序里,再从程序可靠地走回真实世界。

当你完成一个"温湿度传感器 + OLED 显示 + 串口命令 + ADC 输入 + 报警状态机"的小项目时,你已经不只是会点灯了。你已经开始具备嵌入式工程的基本形状:会接硬件、会读手册、会调协议、会分层、会验证、会把一堆零散外设组织成一个系统。

这时再学 RTOS、网络协议、低功耗、驱动框架、Bootloader,就不再是空中楼阁。你脚下已经有地面了。

相关推荐
振南的单片机世界3 小时前
HAL_Delay(1000)真准吗?SysTick的1ms基准从哪来
arm开发·stm32·单片机·嵌入式硬件
NPE~6 小时前
[嵌入式]从0到1开发环境搭建
stm32·嵌入式硬件·教程·clion·stmcubemx·stmcubeclt
项目題供诗7 小时前
STM32-ADC模数转换器(十八)
stm32·单片机·嵌入式硬件
YYRAN_ZZU7 小时前
Ubuntu22.04搭建QEMU嵌入式开发环境全攻略
linux·嵌入式硬件·ubuntu
_YouziTech_8 小时前
【STM32】U8G2图形库应用--菜单设计与开发
stm32·单片机·嵌入式硬件·oled·开机动画·图形库
2301_805962938 小时前
ESP32 使用 PlatformIO 编译点灯程序
stm32·esp32
Silicore_Emma8 小时前
芯谷科技—D55126 漏电保护器专用集成电路
嵌入式硬件·新能源充电桩·芯谷科技·漏电保护器·高性能cmos漏电保护器·智能断路器/物联网配电·家用漏电保护
国科安芯9 小时前
商业航天级抗辐照全双工RS-485/RS-422收发器ASM491S2Y的技术特性与应用研究
运维·网络·单片机·嵌入式硬件·安全·架构·安全性测试
国科安芯9 小时前
ASP7A84AS高精度抗辐照线性稳压器技术特性与应用分析
单片机·嵌入式硬件·安全·架构