当前位置: 首页 > news >正文

图文并茂:全面了解UART相关知识(TTL+RS232+RS484)

全面了解UART相关知识

  • 1 .UART的发展史
  • 2. UART分层架构
  • 3. LVTTL/TTL、RS232、RS485 的区别
  • 4. UART的连接
  • 5. 协议简介
    • 5.1协议规范
  • 6 UART优缺点
  • 7. 串口编程
    • 7.1 硬件串口配置
      • 7.1.1 通信基本参数(必须配置)
      • 7.1.2. 数据流控 (Flow Control)
      • 7.1.3. 工作模式 (Operation Mode)
    • 7.2 不同芯片的API封装
    • 7.3 软件模拟串口
      • 7.3.1 纯延时模拟
      • 7.3.2 外部中断法
      • 7.3.3 外部中断法+定时器法
      • 7.3.4 时器过采样法(Timer-based Oversampling)
  • 8.常见问题
  • 9.相关参考(扩展):

1 .UART的发展史

UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)是串行通信的基石,其发展史与电子通信技术紧密相关:
在这里插入图片描述

2. UART分层架构

在嵌入式中,分层思想会被大量使用。因为他最大意义就是在于解耦复用。在UART设计中主要分为三层,至下而上:物理层、协议层、应用层(如下图所示)。

  • 解耦:让每一层都只关心自己的“本职工作”,而不需要关心其他层是如何实现的。
    • 应用层 不用关心数据是怎么打包的,也不用关心是用RS232还是RS485传出去的。
    • 物理层 也不用关心传的这些0101是代表温度还是代表一个控制命令。
  • 复用:当需求变更时,您只需要修改对应的层次。
    • 比如,您的产品现在用RS232,将来要升级成RS485。您只需要修改物理层的代码(改个芯片驱动,换个收发器),而您的协议层和应用层代码一行都不用改。
      UART分层
层级功能关键概念
应用层定义数据格式和业务逻辑(如文本、二进制指令)。数据封装(如 AT指令GPS NMEA协议)设备控制(传感器、蓝牙模块)
协议层规定数据帧格式、时序规则(波特率、起始位等)。数据帧结构(起始位+数据位+校验位+停止位)波特率(如 9600bps)
物理层定义电气特性(电压、信号类型)和硬件接口(引脚连接)。电平标准(TTL、RS232、RS485)收发器芯片(MAX232、MAX485)

3. LVTTL/TTL、RS232、RS485 的区别

由于UART协议层的输入是逻辑0/1信号,而逻辑0/1信号在物理层可以通过不同的电平标准来区分。针对不同的通讯需求,便可以使用不同的物理层实现。数字电路中,由TTL电子元器件组成电路使用的电平。电平是个电压范围。具体不同的范围如下图所示:

  1. 对LVTTL器件规定输出高电平大于2.4V且小于3.3V,输出低电平小于0.4V;

  2. 对TTL器件规定输出高电平大于2.4V且小于5V,输出低电平小于0.4V。

  3. 对RS232器件规定输出高电平大于-15V且小于-3V,输出低电平大于3V小于15V。

  4. 对RS484器件规定输出高电平2V<A-B<6V,输出低电平-6V<A-B< -2V。
    UART的电平图
    UART使用标准的TTL/CMOS逻辑电平(0 ~5V,0 ~3.3V)来表示数据,1表示高电平,0表示低电平。为了提高抗干扰能力提高传输的距离,通常也会TTL/CMOS逻辑电平转换为RS-232逻辑电平,(3 ~15V)表示0,(-3 ~ -15V)表示1。举个例子,对于传输数据0x55,即二进制的01010101,RS232、TTL和RS484的区别如下;三种电平的示意图,
    不同类型UART的电平表示

减少噪声的本质增大阈值范围,比如将2.4V到5V提高为3V到15V,使得不容易触发;或使用差分消除变化,比如A-B和阈值比较;

增加传输距离的本质:通过增大电压幅度进而增加能量,使得传输距离变得更远。

对比三种电平标准是 UART 物理层的核心差异如下表:

特性LVTTL/TTLRS232RS485
电平范围0V(逻辑0)/3.3V或5V(逻辑1)±3~15V(逻辑1为负电压)差分信号:A-B > +0.2V(逻辑1)A-B < -0.2V(逻辑0)
传输距离1-2米(易受干扰)15米以内1200米(理论值)
抗干扰能力中等强(差分信号抑制共模噪声)
应用场景芯片间短距离通信(如MCU与传感器)计算机串口(如旧式鼠标)工业总线(Modbus、CAN)
双工模式全双工全双工半双工
通信个数点对点点对点最多128
  • 应用场景选择:短距离用 TTL,中距离用 RS232,工业环境用 RS485。

  • 关键设计要点:电平匹配、波特率一致、抗干扰设计(如 RS485 差分信号)。

  • 常见错误:直接连接 TTL 与 RS232 导致设备损坏,需使用电平转换芯片。

4. UART的连接

  1. 在此以单片机TTL串口进行连接展示,TX→RX、RX→TX、GND→GND。
    在这里插入图片描述
  2. RS232的连接,实在TTL的基础上加上Max232的芯片(或类似芯片)进行转换电平进行减少干扰,增加传输的距离的,具体连接示意图如下:
    在这里插入图片描述
  3. RS484的连接,也是TTL的基础上加上Max484芯片(或类似芯片)进行转换电平,还有RS484采用差分,只需要两根线进行连接两个设备,根据两根线的差值进行处理逻辑电平。连接示意图如下:
    在这里插入图片描述

5. 协议简介

5.1协议规范

协议内容包括:

UART协议帧结构的定义,如下图所示,包括空闲位、开始位,数据位、校验位、停止位。
在这里插入图片描述

  • 波特率:波特率是UART协议,或者说所有异步串行协议,非常重要的一个概念,即单位时间内(1秒)可表示的bit位个数,或者也可以表述为bit位宽的倒数。常见波特率是9600、57600、115200、100000。

    T b = 1 b a u d × 1000 T_{b} = \frac{{{1}}}{{{baud}}} \times1000 Tb=baud1×1000

举一个实际例子进行计算一个字节的大概时间:串口传输速率为9600bps,每秒可传输多少字节?首先计算一个bit需要传输多少时间: 1 ÷ 9600 × 1000 = 104 u s 1 ÷ 9600 \times1000= 104us 1÷9600×1000=104us。一个常规的字节组成:起始位:1bit,数据位:8 bit,停止位:1bit,校验位:0bit,需要传输10bit,因此,即(常规)串口9600波特每个字节传输时间是104usx10=1.04ms

例如,一些常见的波特率及其对应的位周期是(可以更具这个大概计算一下每帧数据的大概时间):

Baud Rate 波特率Bit Period 位周期
9600104us
5760017.4us
1152008.68us
  • 空闲状态:在数据帧之间,UART线路保持在逻辑高电平状态,称为空闲位。这个空闲时间用于下一个数据帧的准备,同时也有助于同步。
    在这里插入图片描述

  • 起始位:UART接收端会一直检测信号线上的电平变化,开始传输数据时,发送端将信号线从高电平拉到低电平结束空闲状态,并保持一个bit位的时长。接收器检测到高低电平转换时,开始接收信号。
    在这里插入图片描述

  • 数据位:数据位包含传输的实际数据,如果使用了奇偶校验,数据帧长度可以是5位到8位。如果不使用奇偶校验位,数据帧长度可以是9位。在一般情况下,数据位为 8 bits,数据首先从最低有效位开始发送,高位在后。
    在这里插入图片描述

  • **奇偶校验位:**接收UART读取数据帧后,将计数值为1的位,检查总数是偶数还是奇数。如果奇偶校验位为0(偶数奇偶校验),则数据帧中的1或逻辑高位总计应为偶数。如果奇偶校验位为1(奇数奇偶校验),则数据帧中的1或逻辑高位总计应为奇数。
    在这里插入图片描述

  • 停止位:为了表示数据包结束,发送UART将数据传输线从低电压驱动到高电压并保持1到2位时间。
    在这里插入图片描述

下图是使用逻辑分析进行抓一个字节数据(0x55)的实际波形:
在这里插入图片描述
地位——>高位 LSB -MSB

6 UART优缺点

UART通信具有其独特的优点和缺点:

  • 优点

    • 实现简单:只需两根线(TXD, RXD)即可实现双向通信,适合资源有限的嵌入式系统。

    • 成本低:由于硬件要求低,制造和维护成本相对低廉。

    • 灵活性高:可调节波特率适应不同传输速度需求,支持多种数据格式。

  • 缺点

    • 传输效率相对较低:相较于SPI等同步通信协议,UART因包含额外的起始位和停止位而降低了有效数据传输效率。

    • 距离限制:虽然可以通过增加驱动电路延长传输距离,但理论上裸线的可靠通信距离较短,易受电磁干扰影响。

    • 不适合高速大数据量传输:波特率上限及异步特性限制了其在高速、大数据量场景下的应用。

7. 串口编程

7.1 硬件串口配置

无论是使用STM32、Nordic、ESP32、国民N32、兆易GD32还是其他芯片,其片上的UART(或USART)外设,其核心配置参数几乎都是差不多的。
这些配置项主要分为以下几类:

7.1.1 通信基本参数(必须配置)

波特率 (Baud Rate)

  • 作用:通信的速率,即每秒传输的比特数 (bps)。
  • 常见值:9600, 19200, 38400, 115200, 921600, 1000000 (1M) 等。
  • 要求:通信双方必须设置成完全一致,允许有很小的误差(通常<2%)。

数据位 (Data Bits)

  • 作用:定义一个“数据包”(一个字节)由几位组成。
  • 常见值:通常是 8 位。在一些老式或特定协议中可能是 7 位或 9 位(第9位有时用于校验或地址)。

停止位 (Stop Bits)

  • 作用:在数据位之后,用于表示一个“数据包”结束的信号。
  • 常见值:1 位。在高速或某些特定配置下可能用 1.5 或 2 位来给接收方更多处理时间。

奇偶校验位 (Parity)

  • 作用:一种简单的错误检测机制。
  • 常见值:
    • None (无校验):最常用,不发送校验位。
    • Even (偶校验):发送的“1”的个数(包括数据位和校验位)必须是偶数。
    • Odd (奇校验):发送的“1”的个数必须是奇数。

7.1.2. 数据流控 (Flow Control)

作用:防止“发送方”发送太快,导致“接收方”的缓冲区溢出(来不及处理)而丢数据。
常见值:

  • None (无流控):默认。接收方必须足够快,或者有足够大的缓冲区。

  • 硬件流控 (RTS/CTS):使用额外的 RTS (Request To Send) 和 CTS (Clear To Send) 两根信号线来“握手”,硬件自动控制,非常可靠。

  • 软件流控 (XON/XOFF):在数据流中发送特殊的控制字符(如 0x11 和 0x13)来通知对方暂停或继续,不占用额外引脚,但可靠性稍差

7.1.3. 工作模式 (Operation Mode)

作用:配置CPU如何与UART外设协作来发送和接收数据。
常见值:

  • 轮询模式 (Polling):CPU不断地检查UART的标志位(如 “是否收到数据?”),最简单但效率最低,会阻塞CPU。

  • 中断模式 (Interrupt)

    • 接收:当UART硬件收到数据并将其放入接收寄存器时,触发一个中断,CPU再去读取数据。
    • 发送:当UART硬件的发送寄存器为空(可以发下一个字节)时,触发一个中断,CPU再去填充数据。这是最平衡、最常用的模式。
  • DMA模式 (Direct Memory Access)

    • 作用:让DMA控制器“绕过CPU”,直接在“内存缓冲区”和“UART外设”之间搬运数据。
    • 优势:在高速(如上M波特率)或大量数据传输时,极大解放CPU。CPU只需要启动一次DMA,然后在DMA传输完成时(通过DMA中断)得到通知即可。

7.2 不同芯片的API封装

HAL(Hardware Abstraction Layer, 硬件抽象层)的设计目标。我们在Nordic SDK和STM32CubeMX中看到的nrfx_uart_…或HAL_UART_…函数,就是厂商帮您做好的第一层抽象。

但是如果我们想适配不同厂商的接口,需要在这些厂商HAL之上,再封装一层您自己的、完全跨平台的API,最经典的做法是“定义一套接口(API)并使用函数指针(Function Pointers)

步骤1:定义“抽象设备接口” (API Interface):这个设计的精妙之处在于应用层、抽象层、具体实现层的三层分离。

#ifndef PLAT_UART_H_
#define PLAT_UART_H_#include <stdint.h>
#include <stddef.h> // for size_t
#include <stdbool.h>/*** @brief 平台UART错误码*/
typedef enum {PLAT_UART_OK            = 0,    // 成功PLAT_UART_E_INVALID_ARG = -1,   // 无效参数PLAT_UART_E_IO_ERROR    = -2,   // 硬件I/O错误PLAT_UART_E_BUSY        = -3,   // 设备忙碌 (例如,正在进行异步发送)PLAT_UART_E_TIMEOUT     = -4,   // 操作超时 (用于阻塞API)PLAT_UART_E_UNSUPPORTED = -5,   // 不支持该功能PLAT_UART_E_NO_MEM      = -6,   // 内存不足 (例如,无法分配缓冲区)
} plat_uart_err_t;/*** @brief 奇偶校验位配置*/
typedef enum {PLAT_UART_PARITY_NONE,PLAT_UART_PARITY_EVEN,PLAT_UART_PARITY_ODD,
} plat_uart_parity_t;/*** @brief 停止位配置*/
typedef enum {PLAT_UART_STOP_BITS_1,PLAT_UART_STOP_BITS_1_5,PLAT_UART_STOP_BITS_2,
} plat_uart_stop_bits_t;/*** @brief IOCTL (I/O 控制) 命令* 用于扩展标准API,执行特定于驱动的操作*/
typedef enum {PLAT_UART_IOCTL_CMD_SET_BAUDRATE,  // 动态设置波特率, arg = (uint32_t *)PLAT_UART_IOCTL_CMD_FLUSH_RX,      // 清空接收缓冲区, arg = NULLPLAT_UART_IOCTL_CMD_GET_RX_COUNT,  // 获取RX缓冲区中待读字节数, arg = (size_t *)// ... 可继续添加
} plat_uart_ioctl_cmd_t;// --- 异步事件系统 (Event System) ---
// 向前声明设备结构体
struct plat_uart_dev;/*** @brief UART 异步事件类型*/
typedef enum {/** @brief 接收到新数据。数据在 buffer 中,长度为 len。 */PLAT_UART_EVENT_RX_READY,/** @brief 异步发送完成。data_ptr 指向被发送的数据缓冲区。*/PLAT_UART_EVENT_TX_DONE,/** @brief 接收缓冲区已满或DMA溢出。*/PLAT_UART_EVENT_ERROR_RX_OVERRUN,/** @brief 发生奇偶校验错误。*/PLAT_UART_EVENT_ERROR_PARITY,/** @brief 发生帧错误 (停止位未检测到)。*/PLAT_UART_EVENT_ERROR_FRAMING,} plat_uart_event_type_t;/*** @brief UART 异步事件结构体*/
typedef struct {plat_uart_event_type_t type;union {/** @brief 仅用于 PLAT_UART_EVENT_RX_READY */struct {uint8_t *buffer; // 指向驱动内部的接收缓冲区size_t len;      // 本次接收到的数据长度} rx_ready;/** @brief 仅用于 PLAT_UART_EVENT_TX_DONE */struct {const void *data_ptr; // 指向应用层传入的、已发送完毕的数据} tx_done;// 错误事件通常不需要额外数据} data;
} plat_uart_event_t;/*** @brief 异步事件回调函数原型* @param dev 触发事件的UART设备* @param event 包含事件详情的结构体* @param user_context 用户在init时传入的上下文指针*/
typedef void (*plat_uart_event_callback_t)(const struct plat_uart_dev *dev, const plat_uart_event_t *event, void *user_context);// --- 配置与API结构体 ---/*** @brief UART 初始化配置结构体*/
typedef struct {uint32_t baud_rate;plat_uart_parity_t parity;plat_uart_stop_bits_t stop_bits;bool hw_flow_control;// 异步事件处理plat_uart_event_callback_t callback;     // 事件回调函数void *user_context;                      // 传递给回调的上下文// ... 可选: data_bits, rx_buffer_size, ...
} plat_uart_cfg_t;/*** @brief UART 驱动API "虚函数表" (V-Table)* 这是所有具体驱动 (STM32, Nordic) 必须实现的函数指针集合。*/
typedef struct {/*** @brief 初始化UART外设*/plat_uart_err_t (*init)(const struct plat_uart_dev *dev, const plat_uart_cfg_t *cfg);/*** @brief 反初始化UART外设*/plat_uart_err_t (*deinit)(const struct plat_uart_dev *dev);/*** @brief (阻塞) 发送数据* @return 成功时返回 PLAT_UART_OK, 否则返回错误码*/plat_uart_err_t (*write_blocking)(const struct plat_uart_dev *dev, const void *data, size_t len, uint32_t timeout_ms);/*** @brief (阻塞) 接收数据* @param dev 设备* @param buffer 接收缓冲区* @param len_to_read 期望读取的字节数* @param timeout_ms 超时时间* @return 实际读取到的字节数。0 表示超时。负数表示错误码 (plat_uart_err_t)。*/int (*read_blocking)(const struct plat_uart_dev *dev, void *buffer, size_t len_to_read, uint32_t timeout_ms);/*** @brief (异步) 发送数据* 函数会立即返回。发送完成后,将通过回调触发 PLAT_UART_EVENT_TX_DONE 事件。* @return PLAT_UART_OK 启动成功, PLAT_UART_E_BUSY 如果上一次异步发送未完成。*/plat_uart_err_t (*write_async)(const struct plat_uart_dev *dev, const void *data, size_t len);/*** @brief 启用异步接收* (通常在 init 时已自动启用,但提供API用于精细控制)*/plat_uart_err_t (*rx_enable)(const struct plat_uart_dev *dev);/*** @brief 禁用异步接收*/plat_uart_err_t (*rx_disable)(const struct plat_uart_dev *dev);/*** @brief I/O 控制接口*/plat_uart_err_t (*ioctl)(const struct plat_uart_dev *dev, uint32_t cmd, void *arg);} plat_uart_api_t;/*** @brief 平台UART设备 "句柄" (Handle)* 这是应用层持有的 "对象"。它将 "API(方法)" 和 "数据(成员)" 绑定在一起。*/
typedef struct plat_uart_dev {const plat_uart_api_t *api; // 指向该设备的 "API函数表"const void *hw_cfg;         // 指向 "只读" 的硬件配置 (如: 寄存器基地址, DMA通道)void *data;                 // 指向 "可读写" 的运行时数据 (如: HAL句柄, 内部缓冲区, 标志位)
} plat_uart_dev_t;#endif // PLAT_UART_H_

步骤2:具体实现层(stm32_uart.c)
需要为stm32的芯片实现 plat_uart_api_t 中的所有函数。

// stm32_uart.c (伪代码)
#include "plat_uart.h"
#include "stm32_hal.h" // 引用厂商HAL
// --- 定义私有数据结构 ---
struct stm32_uart_hw_cfg {USART_TypeDef *instance; // (只读) 硬件寄存器地址// ... DMA通道、时钟等 ...
};struct stm32_uart_data {UART_HandleTypeDef huart;   // (读写) ST的HAL句柄plat_uart_event_callback_t app_callback; // 保存应用层回调void *app_context;uint8_t rx_dma_buffer[128]; // 自己的DMA接收环形缓冲区// ... 标志位, semaphores, ...
};// --- 实现API函数 ---
static plat_uart_err_t stm32_uart_init(const struct plat_uart_dev *dev, const plat_uart_cfg_t *cfg) 
{// 1. 从dev句柄中获取私有数据struct stm32_uart_data *data = dev->data;const struct stm32_uart_hw_cfg *hw_cfg = dev->hw_cfg;// 2. 配置 data->huart (ST的HAL句柄)data->huart.Instance = hw_cfg->instance;data->huart.Init.BaudRate = cfg->baud_rate;// ...HAL_UART_Init(&data->huart);// 3. 保存回调data->app_callback = cfg->callback;data->app_context = cfg->user_context;// 4. 启动异步接收 (例如,使用DMA环形缓冲区)HAL_UART_Receive_DMA(&data->huart, data->rx_dma_buffer, 128);return PLAT_UART_OK;
}static plat_uart_err_t stm32_uart_write_async(const struct plat_uart_dev *dev, const void *data, size_t len)
{struct stm32_uart_data *drv_data = dev->data;// (需要逻辑判断 huart.gState 是否忙碌)if (HAL_UART_GetState(&drv_data->huart) != HAL_UART_STATE_READY) {return PLAT_UART_E_BUSY;}// 启动异步DMA发送if (HAL_UART_Transmit_DMA(&drv_data->huart, (uint8_t*)data, len) != HAL_OK) {return PLAT_UART_E_IO_ERROR;}return PLAT_UART_OK;
}// (省略其他函数的实现...)// --- ST的HAL中断回调 (在 stm32_it.c 中) ---
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {// 1. 根据 huart->Instance 找到对应的 plat_uart_dev *dev// (这需要一个查找表或在data中反向引用)// 假设我们找到了 dev 和 drv_dataconst struct plat_uart_dev *dev = ...; struct stm32_uart_data *drv_data = dev->data;// 2. 准备事件plat_uart_event_t event = {.type = PLAT_UART_EVENT_TX_DONE,.data.tx_done.data_ptr = huart->pTxBuffPtr // (ST HAL保存了指针)};// 3. 通知应用层if (drv_data->app_callback) {drv_data->app_callback(dev, &event, drv_data->app_context);}
}// (同理实现 HAL_UART_RxCpltCallback, HAL_UART_ErrorCallback)// --- 绑定API和数据 ---
static const plat_uart_api_t stm32_uart_api_funcs = {.init = stm32_uart_init,.write_async = stm32_uart_write_async,// ... 绑定所有其他函数 ...
};// --- 实例化设备 (对外暴露) ---
static struct stm32_uart_data g_uart1_data; // 运行时数据
static const struct stm32_uart_hw_cfg g_uart1_hw_cfg = { // 只读硬件配置.instance = USART1,
};// 这是应用层唯一可见的 "UART1" 对象
plat_uart_dev_t g_uart1_dev = {.api = &stm32_uart_api_funcs,.hw_cfg = &g_uart1_hw_cfg,.data = &g_uart1_data,
};

我们也可为 Nordic 的 nrfx_uarte 驱动编写一个 nordic_uart.c,实现完全相同的功能。

步骤3:应用层调用 (main.c)

// main.c
#include "plat_uart.h"
// 从具体实现文件中 "导入" 设备实例
// 如果切换到Nordic平台,这里只需 extern nordic_uart_dev 即可
extern plat_uart_dev_t g_uart1_dev;// 获取设备句柄 (可以是指向 g_uart1_dev 的指针)
const plat_uart_dev_t *my_serial_port = &g_uart1_dev;// --- 应用层事件处理器 ---
void my_uart_event_handler(const struct plat_uart_dev *dev, const plat_uart_event_t *event, void *user_context)
{// (void)dev; // 检查是哪个设备触发的// (void)user_context; // 获取用户上下文switch (event->type) {case PLAT_UART_EVENT_RX_READY:printf("APP: 收到 %d 字节数据: [", event->data.rx_ready.len);for(size_t i=0; i < event->data.rx_ready.len; i++) {printf("%02X ", event->data.rx_ready.buffer[i]);}printf("]\n");// 注意:event->data.rx_ready.buffer 是驱动的内部缓冲区// 您需要在这里将其拷贝走,否则数据可能被覆盖// memcpy(my_app_buffer, event->data.rx_ready.buffer, ...);break;case PLAT_UART_EVENT_TX_DONE:printf("APP: 异步发送完成!\n");// 在这里可以释放 data_ptr 或启动下一次发送break;case PLAT_UART_EVENT_ERROR_RX_OVERRUN:printf("APP: 糟糕,数据接收溢出!\n");break;default:break;}
}int main(void) {// ... 系统初始化 ...// 1. 配置UARTplat_uart_cfg_t uart_cfg = {.baud_rate = 115200,.parity = PLAT_UART_PARITY_NONE,.stop_bits = PLAT_UART_STOP_BITS_1,.hw_flow_control = false,.callback = my_uart_event_handler, // 注册回调.user_context = NULL,              // (可以传this指针或任何东西)};// 2. 初始化设备 (调用抽象API)my_serial_port->api->init(my_serial_port, &uart_cfg);// 3. 启动异步接收 (通常init已做,这里为演示)my_serial_port->api->rx_enable(my_serial_port);uint8_t hello_msg[] = { 0xDE, 0xAD, 0xBE, 0xEF };while (1) {// 4. (异步) 发送数据// 这个调用会立即返回,CPU继续执行while循环// 发送完成后,`my_uart_event_handler` 会被自动调用my_serial_port->api->write_async(my_serial_port, hello_msg, sizeof(hello_msg));// ... (CPU可以在这里做其他事,比如休眠) ...// k_sleep(K_MSEC(1000)); // (如果您在Zephyr中)// 5. (阻塞) 接收演示// char cmd_buffer[10];// int bytes_read = my_serial_port->api->read_blocking(my_serial_port, cmd_buffer, 10, 5000);// if (bytes_read > 0) {//    printf("阻塞读取到 %d 字节\n", bytes_read);// }}
}

7.3 软件模拟串口

如果我们的硬件串口不够用时,也会考虑使用软件模拟串口进行功能调试使用,下面是软件模拟相关实现。

7.3.1 纯延时模拟

从一个电平到下一个电平的过程均采用硬延时,然而这里的延时就是对应着波特率所规定的电平持续时间,传输1位所需要的时间 T = 1/9600 约为104.167us,那么我们只需要按照对应的格式翻转IO口,然后delay延时对应的时间即可完成模拟。

void IO_UartSend( sUart *pUart,unsigned char byte){unsigned char bitCnt = 8;pUart->SetTxPin(pUart,PIN_LOW);             //发送 Start bitpUart->BaudDelay(pUart);                    //根据baudRate延时 while(bitCnt--)                             //循环发送data bit {pUart->SetTxPin(pUart,(pUart & 0x01));   //发送 Start bit    byte >>= 1;                             //移位所发数据 pUart->BaudDelay(pUart);                //根据baudRate延时  }pUart->SetTxPin(pUart,PIN_HIGH);            //发送stop bit pUart->BaudDelay(pUart);                    //根据baudRate延时 
}
unsigned char IO_UartRecv(sUart *pUart)
{unsigned char Recv;unsigned char bitCnt = 8;while(!pUart->GetRxPin(pUart))                 //如果接受到低电平起始位 {pUart->BaudDelay(pUart);                    //根据baudRate延时 while(bitCnt--){Recv >>= 1;if(pUart->GetRxPin(pUart))Recv |= 0x80; //如果接受到电平为1,则置位 pUart->BaudDelay(pUart);                //根据baudRate延时 }}return Rev;                                      //最终返回接受到的数据 
}

分析

  • 上面主要是IO口模拟串口的发送和接受,发送相对比较简单,接受部分通过不断的查询对应的接收引脚是否已经拉低成为低电平,如果拉低成为了低电平就认为接受到了start_bit,后面便通过延时进行后面数据的接收。然而其中根据波特率进行的延时一般就直接用指令周期来进行测量延时了。

  • 此方法对于简单的模拟串口收发功能基本实现了,不过其只能实现通信的半双工,同时通过不断的查询RX的电平状态比较浪费CPU资源,那么需要进一步改善。

7.3.2 外部中断法

查询比较耗费时间和资源,那么自然而然就想到采用中断的方法来进行处理。这种“外部中断 + 延迟”的方法是早期资源匮乏的MCU(比如没有硬件UART或UART不够用时)模拟串口(Software UART / “Bit-banging”)的常用手段。

模拟串口的“外部中断法”原理
这种方法的核心原理是:用硬件中断来解决“何时开始接收”这个最关键的时序问题,然后用软件延迟来接管后续的“数据位采样”。
在这里插入图片描述

其工作流程如下

  1. 配置与等待

    • 将UART的RX引脚配置为外部中断输入。
    • 中断触发方式设置为下降沿(Falling Edge)。
    • CPU空闲时(或在执行主循环),RX引脚处于空闲电平(高电平)。
  2. 中断触发(Start Bit)

    • 当“起始位”(Start Bit)到来时,RX电平从高跳变为低。
    • 这个下降沿立即触发了外部中断。
  3. 中断服务程序 (ISR) 执行

    • CPU跳转到中断服务程序(ISR)。
    • 关键步骤1:关闭中断。 立即禁用该引脚的外部中断功能。这是为了防止后续的数据位(例如一个从1到0的变化)错误地再次触发“起始位”中断。
    • 关键步骤2:定位采样点。 正如您资料中指出的,为了避免在电平变化的“边缘”(上升/下降时间)采样,程序会延迟半个比特周期(0.5 * T_bit)。T_bit = 1 / 波特率。例如,9600波特率,T_bit 约 104µs,半个周期就是 52µs。
    • (可选但推荐):延迟 52µs 后,再次检查引脚电平。如果此时引脚是高电平,说明刚刚的下降沿只是一个“毛刺”或噪声,应立即放弃本次接收,重新打开中断并退出ISR。如果仍是低电平,则确认是一个有效的起始位。
  4. 数据位采样 (Bit Sampling)

    • 程序进入一个循环(例如8次,对应8个数据位)。
    • 在循环中,每次都精确延迟一个比特周期(1.0 * T_bit,即 104µs)。
    • 每次延迟结束后,CPU都正好位于下一个数据位的“正中间”。
    • 读取引脚电平:高电平记为1,低电平记为0。
    • 将读取的位(从LSB到MSB)存入一个变量中。
  5. 停止位与清理 (Stop Bit & Cleanup)

    • 8个数据位采样完毕后,再延迟一个比特周期(1.0 * T_bit)来定位到“停止位”(Stop Bit)的中间。
    • (可选):检查停止位是否为高电平。如果为低,说明发生了“帧错误”(Framing Error)。
    • 此时,一个字节(一帧数据)已完整接收。将拼装好的数据字节放入接收缓冲区。
    • 关键步骤3:重新打开中断。 重新使能该引脚的下降沿外部中断,准备接收下一个数据帧。
    • 退出ISR。

外部中断法”的严重不足之处
您资料中提到的“硬延时”和“无法全双工”是两个最大的问题。这种方法在现代嵌入式设计中被视为“不良实践”,原因如下:

  1. 中断服务程序 (ISR) 占用时间过长(“硬延时”)这是最致命的缺点。中断服务程序(ISR)本应“快进快出”,但这个方法却让CPU长时间“阻塞”在ISR内部。计算:以9600波特率为例,接收1个起始位 + 8个数据位 + 1个停止位 = 10个比特位。总阻塞时间:10 * 104µs ≈ 1.04ms。会造成下面后果:

    • 实时性崩溃:在这 1.04ms 期间,CPU 无法响应任何同优先级或更低优先级的中断。如果此时有一个定时器中断(如PWM控制)或另一个外部按键中断到达,它们都将被推迟到这 1.04ms 结束之后才能执行,这对实时系统是灾难性的。
    • CPU资源浪费:CPU在delay()循环中空转,没有执行任何有意义的计算,效率极低。
  2. 无法实现全双工 (Not Full-Duplex)
    原理:全双工要求CPU能够同时发送数据和接收数据。会造成冲突:

    • 接收时:CPU被上述的接收ISR“绑架”了 1.04ms。
    • 发送时:模拟发送(Bit-banging Send)同样需要CPU精确控制引脚高低电平并“硬延时”。
    • 结果:CPU无法同时既在接收ISR里delay(),又在主循环里delay()发送。如果系统正在发送一个字节(占用 1.04ms),此时一个起始位中断到来,系统将无法正确响应,导致接收数据丢失或错乱。
  3. 累积的定时误差 (Cumulative Timing Error)
    问题:该方法对“1个比特周期”的软件delay()精度要求极高。误差源:

    • MCU 的时钟源(如内部RC振荡器)本身就有温漂和个体差异。
    • delay()函数可能被更高优先级的中断(如RTOS的SysTick)打断,导致延迟时间不准。
    • 误差累积:对 LSB(第一位)的采样误差很小(1.5 * T_bit 的误差),但对 MSB(第八位)的采样误差是 LSB 误差的 8 倍(8.5 * T_bit 的总误差)。
    • 结果:在波特率较高或时钟不准时,采样点会逐渐“漂移”,最终可能漂移到比特的边缘甚至下一个比特,导致数据误判。
  4. 抗干扰能力差 (Poor Noise Immunity)

    • 问题:此方法在每个比特周期的“中间”只采样一次。
    • 风险:如果在这个精确的采样瞬间,线路上出现一个短暂的噪声脉冲(Noise Spike),导致电平翻转(例如本应是1,但噪声使它瞬间变为0),程序就会采样到错误的数据。
    • 对比硬件UART:硬件UART通常使用“过采样”(Oversampling)技术,比如在一个比特周期内采样16次,然后通过“多数表决”(e.g., 16次采样中有12次是高电平,则判为1)来滤除噪声。

7.3.3 外部中断法+定时器法

如果了解上面的单中断的缺点后,为了解决了上一个方法中最致命的“ISR长时间阻塞”问题,可以使用“外部中断 + 定时器”法来进行处理。
这种方法的核心思想是:“分工合作”

  • 外部中断 (ExtInt):只负责一件事 —— 异步地捕捉“起始位”的到来。
  • 硬件定时器 (Timer):接管后续所有的时间敏感工作 —— 精确地调度“何时去采样”。
    在这里插入图片描述

工作流程详解

  1. 等待状态 (Idle)

    • CPU 正常运行主程序。
    • RX 引脚配置为下降沿外部中断,并已使能。
    • 一个硬件定时器被配置好(例如,知道一个比特周期 T_bit 是104µs),但尚未启动。
  2. 步骤一外部中断ISR(必须极快

    • 当“起始位”到来,RX引脚产生下降沿,触发外部中断。
    • CPU 进入 ExtInt ISR。
    • a. 立即禁用该引脚的外部中断(防止后续数据位再次触发)。
    • b. 启动定时器:设置定时器在 0.5 比特周期(例如 52µs)后触发一次中断。
    • c. 立即退出 ExtInt ISR。
    • (注意:这个ISR的执行时间极短,可能只有几微秒。CPU 立即返回主程序。)
  3. 步骤二:定时器ISR(成为“主力”)

    • CPU 返回主程序继续工作了 52µs
    • 定时器中断 1 (在 0.5T 时刻):
    • CPU 进入 Timer ISR。
      • a. 采样起始位:读取 RX 引脚。如果为高电平,说明是噪声,立即停止定时器、重新使能 ExtInt 并退出。
      • b. 确认有效:如果为低电平,说明是有效的起始位。
      • c. 改变周期并重装:将定时器的周期改为 1.0 比特周期(例如 104µs)。
      • d. 初始化一个状态机(如 bit_count = 0)。
      • e. 退出 Timer ISR。
  4. 步骤三:循环采样(状态机工作)

    • CPU 又工作了 104µs

    • 定时器中断 2 (在 1.5T 时刻):

    • Timer ISR 触发。

      • a. 采样数据位0 (LSB):读取 RX 引脚电平。
      • b. 存储数据:将该位存入一个 received_byte 变量。
      • c. bit_count 增加。
      • d. 退出 Timer ISR。(定时器会自动在 104µs 后再次触发)
    • 定时器中断 3 ~ 9 (在 2.5T 至 8.5T 时刻):

      • 重复上述过程,依次采样数据位 1 到 7 (MSB)。
  5. 步骤四:停止位与清理

    • 定时器中断 10 (在 9.5T 时刻):
    • Timer ISR 触发。
      • a. 采样停止位:读取 RX 引脚电平。
      • b. 帧校验:检查该位是否为高电平。
      • c. 接收完成:如果为高,received_byte 有效,将其存入接收FIFO缓冲区;如果为低,发生帧错误,丢弃该字节。
      • d. 停止定时器。
      • e. 重新使能 ExtInt:重新打开 RX 引脚的下降沿外部中断,准备接收下一个数据帧。
      • f. 退出 Timer ISR。

这个方法的优点有:

  1. 非阻塞ISR,CPU效率极高 (最核心优点)

    • 无论是 ExtInt ISR 还是 Timer ISR,它们的执行时间都非常短(“快进快出”)。
    • CPU 不再被delay()函数“绑架”长达 1ms。在两个定时器中断的间隙(例如104µs),CPU可以自由地执行主循环任务,或响应其他中断。
  2. 实时性大大提高

    • 由于 ISR 不被阻塞,其他同优先级或低优先级的中断(如按键、ADC、其他定时器)都能够得到及时响应,系统的实时性得到了保证。
  3. 定时精度高

    • 它依赖的是硬件定时器,其精度和稳定性远高于软件delay()。这大大减少了因时钟漂移或中断嵌套导致的累积计时误差。
  4. 可以实现“半双工”

    • 由于接收不再阻塞CPU,主循环现在可以(在空闲时)调用一个模拟发送函数(发送本身仍然是阻塞的)。这使得“分时复用”的半双工通信成为可能。

缺点 (Cons)

  1. 硬件资源消耗大 (最核心缺点):正如您资料中所指出的,每模拟一个串口,就需要“1个外部中断引脚 + 1个独立的硬件定时器”。在 MCU 上,硬件定时器(尤其是可灵活配置的 TIM)是非常宝贵的资源,用一个定时器只服务一个串口非常浪费。

  2. 中断开销(Overhead)大:为了接收一个字节(Byte),系统需要总共进入11次中断(1次ExtInt + 10次Timer ISR)。在低波特率(如9600)下,这完全没问题。但在高波特率(如115200)下,一个比特周期仅为 8.7µs。这意味着系统需要每 8.7µs 产生并处理一次定时器中断,这对于CPU的负担(上下文切换、进出ISR)非常重。

  3. 仍然不是全双工 (Not Full-Duplex):虽然接收是非阻塞的,但模拟发送(Bit-banging Send)仍然需要精确的软件delay()来控制时序,这个过程是阻塞的。如果CPU正在执行阻塞的“发送”任务时,一个“接收”的 ExtInt 来了,时序将彻底混乱。

  4. 抗干扰能力依然有限:此方法在每个比特周期的“中心点”只采样一次。如果这个时刻恰好有一个噪声脉冲,数据采样依然会出错。它没有硬件UART的“过采样+投票表决”机制。

7.3.4 时器过采样法(Timer-based Oversampling)

这种方法甚至不需要外部中断或输入捕获,它只使用一个硬件定时器,就可以模拟多个全双工串口。

核心原理

  1. “用一个高速定时器,去‘轮询’所有引脚“。
  2. 设置一个定时器,让它以一个远高于波特率的频率触发中断。这个频率通常是波特率的8倍或16倍(这称为“过采样率”)。
    • 例如:模拟9600波特率,使用16倍过采样,定时器中断频率 = 9600 * 16 = 153,600 Hz (约每 6.5µs 中断一次)。

实现步骤

  1. 系统为每一个模拟串口都维护一个独立的状态机(State Machine)。

  2. 高速定时器ISR (e.g., 每 6.5µs 触发一次)

    • ISR被触发。
    • 程序遍历所有需要模拟的串口(Port A, Port B, Port C…)。
  3. 对每个串口,根据其当前状态执行操作:

    • 状态 IDLE (空闲):

      • 读取RX引脚电平。
      • 如果电平为“低”,说明可能是一个“起始位”。
      • 动作:将状态切换到 START_BIT,并重置一个“过采样计数器” sample_count = 16。
    • 状态 START_BIT (起始位确认):

      • sample_count–。
      • 当sample_count减到 8 时(即达到了起始位的正中心):
      • 再次读取RX引脚。如果为“高”,说明是噪声,动作:切回 IDLE。
      • 如果为“低”,确认是有效起始位。动作:切到 DATA 状态,重置 sample_count = 16(等待一个完整比特),bit_index = 0。
    • 状态 DATA (数据位采样):

      • sample_count–。
      • 当sample_count减到 0 时(即达到了下一个数据位的正中心):
      • 读取RX引脚电平,存入 received_byte 的第 bit_index 位。
      • bit_index++。
      • 动作:重置 sample_count = 16。
      • 如果 bit_index == 8,动作:切到 STOP_BIT 状态。
    • 状态 STOP_BIT (停止位采样):

      • sample_count–。
      • 当sample_count减到 0 时:
      • 读取RX引脚电平。如果为“高”,数据有效,存入FIFO。如果为“低”,帧错误。
      • 动作:切回 IDLE 状态,准备接收下一帧。

优点分析

  • 资源占用极省:只需 1 个定时器,就可以同时驱动N个模拟串口(RX和TX)。这是它相比“ExtInt+Timer”法(N个串口需要N个定时器)的压倒性优势。
  • 抗干扰性强:它天生就是“过采样”。你可以在 sample_count 减到 7, 8, 9 时分别采样,然后“三取二”投票,抗噪声能力媲美硬件UART。
  • 可实现全双工:同一个定时器ISR,在处理RX状态机的同时,也可以处理TX(发送)状态机(每16次中断,发送一位数据),轻松实现全双工。
  • 无需外部中断:引脚只是普通的GPIO,不需要抢占宝贵的“外部中断”或“捕获”资源。

结论:“定时器过采样法”是现代嵌入式软件中实现模拟串口(Software UART)的标准工业级实践。

8.常见问题

  • 接线问题:初级学者经常犯的错误就是将RX和TX线错误连接,因此在遇到连接不通的时候一定要先检查确定一下是否存在这方面的问题。

  • 波特率失配问题:如果数据以9600bps的波特率传输,并以19200bps的速率接收。 收到的数据将是一团垃圾! 波特率必须在发送端和接收端匹配,这是UART串行通信的经验法则,波特率的最大允许偏移趋于介于(1-2%)之间。 因此尝试在两端生成完全相同的波特率,以避免错配错误。

  • 通信线太长问题:TTL电平的UART仅支持5V的电压摆幅,电缆越长电阻也就越高,电缆的电容,电容效应会阻碍信号电平的变化,线太长影响通信质量。

  • DMA发送数据不完整:DMA传输期间源缓冲区被意外修改。主要出现在使用局部变量进行dam缓存时。

9.相关参考(扩展):

https://dreamsourcelab.cn/logic-analyzer/uart/
https://blog.csdn.net/m0_74936872/article/details/131013086
https://doc.embedfire.com/linux/imx6/base/zh/latest/bare_metal/uart.html

http://www.dtcms.com/a/502867.html

相关文章:

  • VMware Euler系统Ctrl+C/V共享剪贴板完全指南:从配置到彻底清理
  • IOT项目——STM32
  • 【物联网架构】
  • 【编程】IDEA自定义系统注解格式|自定义自定义注解格式
  • 定位网站关键词dw网页制作模板源代码
  • 【Linux网络】封装Socket
  • Solidity智能合约开发入门攻略
  • AI决策系统:从数据到行动的智能跃迁——底层逻辑与实践全景解析
  • 好看的单页面网站石岩网站设计
  • 未来的 AI 操作系统(二)——世界即界面:自然语言成为新的人机交互协议
  • 经典排序算法的实现与解析
  • 流量转化与生态重构:“开源AI智能名片链动2+1模式S2B2C商城小程序”对直播电商的范式革新
  • Docker 常用命总结
  • git 和 tortoisegit的快速使用教学(上传至gitee或GitHub)
  • 基于单片机的智能家居多参数环境监测与联动报警系统设计
  • OpenHarmony 6.0 低空飞行器开发实战:从AI感知检测到组网协同
  • 专业做网站排名的人做短视频网站
  • 从协议到工程:一款超低延迟RTSP/RTMP播放器的系统级设计剖析
  • Visio 2024 下载安装教程,安装包
  • 郑州做网站公司+卓美电子商务网页设计试题
  • Java 大视界 -- 基于 Java 的大数据实时流处理在工业物联网设备状态监测中的应用与挑战
  • ESP3266 NodeMCU 使用Arduino点亮 ST7789 240x240 tft屏
  • OpenHarmony平台大语言模型本地推理:llama深度适配与部署技术详解
  • OpenHarmony 的 DataAbility:从 URI 到跨设备数据共享的完整解析
  • ipv6 over ipv4隧道技术
  • 谷歌下载官网舆情优化公司
  • 桐城网站设计做小程序用什么软件
  • 【小学教辅】六年级上册语文知识点课课贴(8页)PDF 重点课文解析 生字词易错题整理 电子版可下载打印|夸克网盘
  • 17.AVL树的实现(一)
  • 如何向文件夹内所有PDF增加水印