69_如何给自己手搓一个串口

如何给自己手搓一个串口:从历史到实践

一、发展历史

20世纪中叶,随着电传打字机(Teletype,简称TTY)的广泛应用,20mA电流环成为早期数据通信的主流物理标准。这种接口采用电流的有无,而非电压的高低来表示逻辑状态:一个完整的20mA电流回流路径代表逻辑0(空号,Space),电流中断则代表逻辑1(传号,Mark)。电流环的设计天然具备强抗干扰能力和长距离传输优势(可达数公里),并且能够通过简单的光电耦合器实现设备间的电气隔离。

更为关键的是,电传打字机确立了异步串行通信的帧格式:通信线路在空闲时保持电流持续(传号状态),一个字符的传输以一个电流中断作为起始位开始,紧接着是58个代表字符信息的数据位,末尾以一个电流恢复的停止位宣告结束。这种起-止式协议,让机械式的电传打字机无需严格的同步时钟,仅靠自身的凸轮分配器就能在收到起始信号后恢复出各位数据,极大地简化了设备复杂度。

到了20世纪60年代,随着电子工业从分立元件向集成电路过渡,设备内部普遍采用晶体管-晶体管逻辑(TTL)。此时,笨重且高功耗的20mA电流环在短距离设备互联中逐渐显得不合时宜,业界开始寻求一种更便捷的电压型接口标准。美国电子工业协会(EIA)正是在这个背景下,借鉴了电流环业已成熟的起-止式异步帧格式和波特率概念,但在物理层上做出了革命性改变:用双极性的电压信号取代了电流信号。

由此诞生了RS-232标准,它定义了以±3V±15V的电压来表示逻辑状态的负逻辑,并将原本的20mA电流源驱动替换为电压源驱动,使得DTE(数据终端设备)与DCE(数据通信设备)之间可以通过标准的DB-25(及后来更精简的DB-9)连接器直接连接。这一转变,标志着串行通信从电流环的工业电报时代,正式迈入了以电压接口为核心的通用数字通信时代。

二、一根导线,两个世界

假想美洲的杰克与法国的露丝之间,只有一根跨越海底的导线。这唯一的一根线,既是信号的载体,也是电流回路的命脉。

为了让这根导线"活"起来,露丝在法国那边接上了一组电池。电池的正极接导线,负极则埋入大地。杰克在美洲这边,把导线引到自己面前的一台电传打字机里,再通过一个开关,最终也接入大地。当杰克的开关闭合时,电流就从露丝的电池正极出发,跨过整片大西洋来到杰克面前,钻进他的电传机,再穿过开关,从大地奔流回露丝的电池负极------一个完整的电流回路就此闭合。露丝用她的电池向这导线注入能量,杰克则用他的开关控制这电流的通断。一根导线,一个全球环路,他们两人的命运就被这条看不见的电流之绳系在了一起。

在这个电流回路里,他们做了一个朴素而坚定的约定:回路中有20mA的电流持续流过,意味着"什么都没发生",他们称这个状态为传号;电流中断,则是"有事情要发生了",称为空号。这就像一根横跨大西洋的"电绳",平时杰克一直拉紧着它。这拉紧的力量,其实就来自露丝那头电池不断注入的电流。只有当杰克要发送信息时,他才会突然松开手。

1. 空闲状态:等待,是通信的开始

故事场景------没人打字的时候,杰克一直保持开关闭合。露丝的电流就这样安安稳稳地在回路里流淌,穿越海底,从美洲回到法国,日复一日,无声无息。这股电流让露丝那头的电磁铁持续吸合,机器安安静静,仿佛在说:"一切都好,我在待命。"

在通信协议中,这个状态被称为空闲状态(Idle)。线路保持高电平,也就是逻辑1,表示没有数据在传输

2. 起始位:一声令下,对话开始

故事场景------杰克敲下了一个字母。他做的第一件事,不是发送字母本身,而是松开开关。回路中的电流瞬间消失。大洋彼岸,露丝的电磁铁"咔嗒"一声松开,机器立刻警醒:一个字符要来了!

在通信协议中,这个动作被称为起始位(Start Bit)。它总是逻辑0,持续一个位周期。它的作用只有一个:用一次从高到低的跳变,唤醒接收方。

单片机发送端代码对照------这个动作,映射到现代单片机上,就是把发送引脚从高电平拉低。如果你的单片机没有硬件UART模块,没关系------你只需要操作一个普通的GPIO引脚,把它写成0就行:

c 复制代码
/*CN:起始位:拉低引脚--EN:Start bit: pull pin low*/
TX = 0;
NOP();NOP();      /*CN:等待一个位周期(由波特率决定)--EN:Wait one bit period (depends on baud rate)*/

就这一行TX = 0;,杰克的"松开开关"就变成了单片机引脚上的一个下降沿。大洋彼岸的露丝------或者说,另一块单片机的RX引脚------就靠捕捉这个下降沿来判断一帧数据的开始。

故事到这,杰克的任务还没结束。

3. 空闲监听:竖起耳朵,等待那个下降沿

故事场景------在杰克松开开关之前,露丝的电磁铁一直被电池电流驱动着,紧紧吸合。她的接收机里有一个由电磁铁控制的棘轮机构,此刻被牢牢吸住,无法转动。机器就这么静静地等着,像一个永远竖着耳朵的守夜人。

在通信协议中,接收方上电后的第一件事,就是持续监测RX线上的电平变化,等待那个标志着起始位的下降沿。

单片机接收端代码对照------在单片机的世界里,没有硬件UART就得用GPIO模拟接收。接收方要做的第一件事,就是不停地扫描RX引脚:

c 复制代码
/*CN:空闲监听:循环等待RX从高变低--EN:Idle monitoring: loop until RX goes from high to low*/
while (RX == 1);   /*CN:一直等到引脚从高变低--EN:Wait until pin goes from high to low*/

疑问? 为什么这里配置的是P3=0啊?接收方不是RX不是P19啊?

问得好!要知道程序是跑在哪里的?是不是跑在右边的MCU上的?所以呢?所以我们就只能配P3喽(同一个导线上电平处处相等 )。

这也是为什么串口要交叉布线的原因。

这一行while (RX == 1);,就是露丝那个一直竖着耳朵的守夜人。CPU在这里死等,什么也不干,就等杰克"松开开关"的那个瞬间。当RX变成0的那一刹那,CPU立刻跳出循环------它知道,通信开始了。

4. 检测起始位:确认这不是误报

故事场景------突然,"咔嗒"一声,电磁铁松开了!电流中断的那一瞬间,棘轮机构被释放,机器的时钟开始滴答滴答地转动起来。露丝的接收机知道:这不是线路故障,这是杰克在说"准备好,我要开始了"。机器立刻启动自己的定时凸轮,准备以和杰克完全相同的节奏,一拍一拍地接收接下来的数据。

在通信协议中,检测到下降沿之后不能立刻开始接收数据。要先等半个位周期,确认起始位是真实信号而非干扰毛刺,然后才能对准第一个数据位的中心开始采样。

单片机接收端代码对照------软件模拟时,跳过起始位的逻辑是这样的:

c 复制代码
/*CN:检测到下降沿后,先等半个位周期确认起始位有效--EN:After detecting falling edge, wait half bit period to confirm start bit*/
NOP();   /*CN:等半个周期,确认这真的是起始位而不是毛刺--EN:Wait half period to confirm it's real start bit*/
if (RX == 0)        /*CN:再次确认确实是低电平--EN:Double check it is low*/
{
    NOP();NOP();    /*CN:再等一个完整周期,现在对准了第一个数据位的正中间--EN:Wait one full period, now aligned to the middle of first data bit*/
    /*CN:接下来开始逐位采样...--EN:Start sampling bit by bit...*/
}

为什么要在下降沿之后再等1.5个位周期?因为起始位本身占据了一个完整的位周期。等半个周期确认它确实是低电平,再等一个完整周期跨过起始位的剩余部分,此时正好对准了第一个数据位的正中间------这就是"中间采样"的关键。

5. 数据位:逐位发送,逐位接收

故事场景------紧接着的五拍时间里,杰克根据字母"A"的鲍多码(即11000)疯狂地断开或闭合开关。电流在导线中跳动着,断了、通了、通了、通了、通了------露丝的电磁铁也随之松开、吸合、吸合、吸合、吸合,在纸带上噼里啪啦地凿下印记。字母"A"就这样从杰克的想法,变成了露丝手中纸带上的一行编码孔。

在通信协议中,数据位的传输有两个关键点:低位在前(LSB先发),以及每一拍的正中间采样。

扩展:什么叫LSB先发?

一、LSBMSB

一个二进制数,位是有高低之分的:

复制代码
二进制:  1    0    1    1    0
         ↑                   ↑
      MSB(最高位)      LSB(最低位)
      第4位              第0位
  • LSB(Least Significant Bit):最低有效位,也就是第0位,权重最小。
  • MSB(Most Significant Bit):最高有效位,也就是最左边那位,权重最大。

二、"先发"是什么意思?

就是在时间上,哪一位先出门。

打个比方------排队出门:

复制代码
教室里有五个人排成一排: [小王] [小李] [小张] [小赵] [小钱]
                          第4位  第3位  第2位  第1位  第0位
MSB 先发:小王第一个出门,然后小李、小张、小赵,最后小钱。
LSB 先发:小钱第一个出门,然后小赵、小张、小李,最后小王。

三、对应到鲍多码11000

复制代码
鲍多码:     1     1     0     0     0
             ↑                      ↑
          第4位                  第0位
          (MSB)                  (LSB)

存入变量 data_byte = 0x03:
二进制:     0     0     0     1     1
             ↑                      ↑
          第4位                  第0位
          (MSB)                  (LSB)

LSB先发的意思是:第0位先走,然后第1位、第2位......

复制代码
发送顺序(LSB先发): 第0位 → 第1位 → 第2位 → 第3位 → 第4位
                       1   →   1   →   0   →   0   →   0
时间方向  ---------------------------------------------------------------------------→

四、为什么串口要LSB先发?

这是历史原因。早期的电传打字机和移位寄存器电路,LSB先发更方便硬件实现------接收方用一个移位寄存器,收到的第一位先进去,之后每来一位就整体往左移,最后自然就变成了正确的字节顺序。这个习惯从19世纪一直保留到今天所有的UART串口中。

五、一句话总结

LSB先发 = 最低位那个01第一个出门,最高位最后出门。就像点名从队尾开始,而不是从队头开始,顺序反着来。

单片机发送端代码对照------鲍多码11000的十进制值是0x03(二进制00011)。因为LSB先发,所以实际发送顺序是反过来的:00011。代码只需要一个循环:

c 复制代码
unsigned char data_byte = 0x03;  /*CN:字母A的鲍多码为11000,低5位是00011--EN:Baudot code for 'A' is 11000, lower 5 bits are 00011*/

/*CN:数据位:逐位发送,LSB先发--EN:Data bits: send bit by bit, LSB first*/
for (int i = 0; i < 5; i++)
{
    if (data_byte & (1 << i))
    {
        TX = 1;     /*CN:位为1,拉高引脚(对应杰克松开开关,无电流)--EN:Bit 1, pull pin high (Jack opens switch, no current)*/
    }
    else
    {
        TX = 0;     /*CN:位为0,拉低引脚(对应杰克闭合开关,有电流)--EN:Bit 0, pull pin low (Jack closes switch, current flows)*/
    }
    NOP();NOP();    /*CN:每个位周期保持一个固定时间--EN:Hold each bit period*/
}

这段代码执行时,TX引脚上的电平变化序列是:低、低、低、高、高------刚好是00011,和杰克那五次开关动作完全对应。没有任何硬件UART寄存器参与,全靠软件在一个GPIO上按照约定时序翻转电平。

单片机接收端代码对照------露丝的棘轮每转过一个角度,就在每一拍的正中间去感受电磁铁的状态。单片机也是一样,每等一个完整位周期后读取RX引脚:

c 复制代码
unsigned char received_byte = 0;

/*CN:逐位采样,LSB先收--EN:Sampling bit by bit, LSB first*/
for (int i = 0; i < 5; i++)
{
    if (RX == 1)
    {
        received_byte |= (1 << i);  /*CN:读到高电平,这一位记为1--EN:Read high, set this bit to 1*/
    }
    /*CN:如果读到低电平,received_byte的这一位保持0,不需要额外操作--EN:If read low, this bit of received_byte stays 0, no extra operation needed*/
    delay_bit();   /*CN:等一个完整位周期,对准下一个数据位的中间--EN:Wait one full bit period, align to the middle of next data bit*/
}

第五个位周期结束后,received_byte的值就是0x03,正是字母"A"的鲍多码。杰克的五次开关动作,就这样被露丝完整地捕捉并在纸带上重现。

6. 停止位:收官与校验

故事场景------发完五位数据,杰克重新闭合开关。露丝的电流再次奔涌而来,电磁铁吸合,持续一个半拍。这就像杰克在说:"这个字符我发完了,你整理一下,我随时可能发下一个。"如果这个时候电磁铁还是松开的,机器就会"咔嚓"一声卡住,亮起一个报警的小红旗------帧错误!说明传输出了问题。

在通信协议中,停止位总是高电平,持续12个位周期,一方面让接收方有时间处理收到的字符,另一方面也提供了一个简单的校验手段。

单片机发送端代码对照------发送完所有数据位后,把引脚拉高并保持:

c 复制代码
/*CN:停止位:拉高引脚,保持至少1个位周期--EN:Stop bit: pull pin high, hold for at least 1 bit period*/
TX = 1;
NOP();NOP();

单片机接收端代码对照------采样停止位,校验是否高电平:

c 复制代码
NOP();NOP();   /*CN:等到停止位的中间--EN:Wait to the middle of stop bit*/
if (RX == 0)
{
    /*CN:帧错误!停止位应该是高电平,现在却是低的--EN:Frame error! Stop bit should be high but is low*/
    /*CN:说明双方波特率可能不匹配,或线路受到干扰--EN:Indicates baud rate mismatch or line interference*/
    frame_error = 1;
}

7. 字符锁存与打印:大功告成

故事场景------停止位校验通过。机器把那五个杠杆的位置拼成一个完整的字符编码,对准一个印字轮,"啪"地一声,油墨透过色带印在纸带上。字母"A"赫然纸上。然后棘轮复位,电磁铁继续保持吸合,所有机构回到原点,等待着下一个起始位带来的惊喜。

单片机代码对照------把收到的字节存入缓冲区,通知主程序来处理:

c 复制代码
RX_Buffer = received_byte;  /*CN:存入接收缓冲区--EN:Store into receive buffer*/
uart_data_ready = 1;        /*CN:置标志位,通知主程序来读取--EN:Set flag, notify main program to read*/

8. 早期起源:电报与电传打字机

串行通信的思想可以追溯到19世纪的电报系统。电报通过一根导线,以串行方式逐个发送莫尔斯电码的"点"和"划",这可以看作是串行通信的雏形。

20世纪中叶,随着电传打字机(Teletype,简称TTY)的出现,人们开始使用电流回路(20mA电流环)来传输字符数据。每个字符被编码成一系列的二进制位(如著名的鲍多码),并按照固定的时间间隔一位接一位地发送。这种起始位 + 数据位 + 停止位的异步串行通信帧格式 成为后来RS-232标准的重要基础。

(1) 鲍多码与起始位同步

早期电传机使用5位鲍多码(Baudot Code)。为了区分空闲状态(常为"1")与有效数据,发送方会在数据位前添加一个"起始位"(通常为"0"),通知接收方准备接收。这个机制至今仍保留在UART(通用异步收发器)中。

9. RS-232标准的诞生与普及

1962年,美国电子工业联盟(EIA)发布了RS-232(Recommended Standard 232)标准,全称为**"数据终端设备(DTE)和数据通信设备(DCE)之间串行二进制数据交换接口"**。这是串口发展史上最重要的里程碑。


a. 电气特性与信号定义

扩展:TTL是什么?

TTL串口是一种基于晶体管-晶体管逻辑(TTL)电平标准的串行通信接口,通常用于嵌入式系统和短距离数据传输。它直接反映了微控制器内部逻辑电平,无需额外的电平转换器即可与数字电路连接。这种较高的电压摆幅提供了更强的抗干扰能力,使得通信距离可达约15米。标准定义了20多条信号线,但实际常用的只有几条:

RS-232TTL电平不同,它采用的是负逻辑:

- 逻辑"1"(MARK): − 3 V -3V −3V到 − 15 V -15V −15V

- 逻辑"0"(SPACE): + 3 V +3V +3V到 + 15 V +15V +15V

i. TXDTransmit Data):发送数据线

ii. RXDReceive Data):接收数据线

iii. GNDGround):信号地线

iv. RTS/CTS:请求发送/允许发送(硬件流控)

v. DTR/DSR:数据终端就绪/数据设备就绪

下表总结了TTL电平与RS-232电平的核心差异,这是理解串口通信物理层的关键。

特性 TTL电平 RS-232电平
逻辑1(高电平) + 2.4 V ∼ + 5 V +2.4V \sim +5V +2.4V∼+5V − 3 V ∼ − 15 V -3V \sim -15V −3V∼−15V(负压)
逻辑0(低电平) 0 V ∼ + 0.8 V 0V \sim +0.8V 0V∼+0.8V + 3 V ∼ + 15 V +3V \sim +15V +3V∼+15V(正压)
典型电压 + 3.3 V +3.3V +3.3V/ + 5 V +5V +5V ± 12 V \pm 12V ±12V
电平极性 正逻辑(高=1,低=0) 负逻辑(负压=1,正压=0)
常用场景 MCUArduino、树莓派 电脑COM口、工业设备
传输距离 约 1 1 1- 2 2 2米 标准最长约 15 15 15米

b. 经典应用:连接调制解调器与计算机

在个人电脑时代初期(20世纪80-90年代),9针或25针的DB型串口是电脑主板上的标配接口。人们用它连接鼠标、外置调制解调器(Modem),通过电话线上网或进行BBS(电子布告栏)通信。"拨号连接"和"AT指令集"成为一代人的共同记忆。

10. 衰落与转型:从主流接口到调试利器

进入21世纪,随着USB(通用串行总线)接口的普及,其高达480MbpsUSB 2.0)甚至更高的速率、热插拔能力以及为外设供电的便利性,迅速取代了串口在消费电子领域的地位。普通电脑主板上已很难看到物理的DB9串口接口。

但是,串口并未消亡,反而在嵌入式、工业控制和开发板领域迎来了新生

1. 嵌入式系统调试

几乎所有微控制器(MCU,如51STM32Arduino)都集成了UART硬件模块。通过USBTTL串口模块(如基于CH340CP2102FT232芯片的模块),开发人员可以方便地让电脑与嵌入式设备通信,打印调试信息、下载程序或进行数据交换。

2. 工业自动化

RS-485标准在RS-232基础上改进,使用差分信号传输,抗干扰能力极强,通信距离可达1200米,且支持多点通信,成为工业现场总线(如Modbus协议)的物理层基础。

3. 网络设备配置

路由器、交换机等网络设备的Console管理口,本质上就是一个串口。网络工程师使用带有RJ45DB9或直接使用USBConsole线缆来登录设备进行初始配置。

三、核心概念

要"手搓"一个串口,本质上是在设计或实现一个符合特定时序和协议的串行通信控制器,也就是控制引脚在某个时刻的高低。所以这需要对串口通信的核心概念有清晰的理解。

1. 什么叫串行通信?什么又叫并行通信?

这是理解串口本质的第一对概念。

(1) 并行通信

数据的多个位(bit同时在多条独立的信号线上传输

例如,一个8位数据需要8根数据线。

- 优点 :传输速度快(单位时间内可传输一个完整的字节)。

- 缺点 :硬件成本高(需要多根线)、线间信号相互干扰(串扰),不适宜长距离传输。

- 例子 :打印机并口(LPT)、IDE硬盘接口、SRAM地址数据总线。


(2) 串行通信

数据的多个位按时间顺序 ,一位接一位地在一条信号线上传输。

- 优点 :节省传输线缆成本、线间无串扰问题、适合长距离传输。

- 缺点 :传输速度相对较慢(需要多个时钟周期才能传完一个字节)。

- 例子 :串口(RS-232/RS-485)、USBI2CSPIPCIe(高速串行)。

简单比喻

假设有8个人要同时通过一个门:

- 并行通信 :并排开8个门,8个人一次同时通过(速度快,但需要8个门)。

- 串行通信 :只开1个门,8个人排好队,一个一个依次通过(速度慢一点,但只需要1个门)。

2. 异步通信 vs 同步通信

这是决定时钟同步方式的两种模式。标准串口(如RS-232)使用的是异步通信

a. 异步通信

通信双方没有独立的时钟信号线。接收方需要从数据信号本身恢复出时钟信息或依赖双方预先约定好的相同波特率。

关键机制:帧同步。每一帧数据由起始位、数据位、校验位(可选)和停止位组成。

i. 起始位(Start Bit :标志一帧数据的开始。当数据线从空闲状态(高电平"1")跳变为低电平"0"并持续一个位时间,接收方检测到该下降沿后,开始在内部计时,准备采样后续的数据位。

ii. 数据位(Data Bits :实际要发送的数据,通常为58位。从最低位(Least Significant BitLSB)开始发送。

iii. 校验位(Parity Bit :用于简单的错误检测。

- 偶校验(Even):数据位加上校验位中"1"的个数为偶数。

- 奇校验(Odd):数据位加上校验位中"1"的个数为奇数。

- 无校验(None)。

iv. 停止位(Stop Bit :标志一帧数据的结束。通常为1位、1.5位或2位的高电平("1")。为下一帧的起始位提供准备时间。

优势 :只需一根数据线(外加地线),不需要复杂的时钟同步电路。

劣势:因包含起始位、停止位等开销,有效数据传输效率低于同步通信。

常见的异步通信有:UARTRS-232RS-485RS-422USB转串口。它们之间的区别是:UART是协议灵魂,RS-232给它换了个高压的外壳,RS-485给它换了个抗干扰的外壳,USB转串口给它换了条USB的通道------但那个"起始位唤醒、中间采样、停止位收官"的约定,从19世纪一直用到今天,从未变过。

b. 同步通信

通信双方使用**独立的时钟信号线(SCLK)**或者在数据线中编码时钟。发送方和接收方在同一时钟的边沿进行数据采样/更新。

例子I2CSPIUSART的同步模式、I²S

3. 波特率(Baud Rate)与比特率(Bit Rate

这两个概念在串口通信中经常被混用,但在高级调制技术下是不同的。对于最简单的串口(NRZ编码),它们是相等的。

1. 波特率 :单位时间内信号状态(符号)变化 的次数,单位是Baud(波特)。

2. 比特率 :单位时间内传输的比特数 ,单位是bpsbits per second)。

公式关系
比特率 = 波特率 × log ⁡ 2 ( M ) \text{比特率} = \text{波特率} \times \log_2(M) 比特率=波特率×log2(M)

其中, M M M是调制电平数(一个符号可以表示几种状态)。

对于串口应用中常见的**NRZ(Non-Return-to-Zero)**编码:

一个符号只有两种电平:高电平(1)和低电平(0)。所以 M = 2 M=2 M=2, log ⁡ 2 ( 2 ) = 1 \log_2(2)=1 log2(2)=1。

此时,比特率 = 波特率

常用串口波特率9600192003840057600115200等。通信双方必须使用完全相同的波特率,否则会产生数据错位或乱码。

4. UART(通用异步收发器)

这是"手搓串口"的核心硬件模块UART是一个将并行数据与串行数据相互转换的集成电路(或IP核)。MCU内部通常都集成了若干个UART外设。

UART的核心功能模块包括:

i. 发送器

- 从CPU并行总线接收一个字节数据。

- 自动添加起始位(0),根据配置添加校验位。

- 在波特率时钟驱动下,将数据帧(起始位+数据位+校验位+停止位)从LSBMSB依次通过TXD引脚串行输出。

ii. 接收器

- 监听RXD引脚的电平变化。

- 检测到有效起始位(高电平到低电平的跳变)后,利用内部波特率时钟对后续数据位进行过采样(通常为16倍采样)或中点采样。

- 将采样到的串行数据位移入移位寄存器,组装成字节。

- 可选的奇偶校验检查。

- 等待停止位到来,完成一帧接收,并将完整字节数据提供给CPU

iii. 波特率发生器

- 通常是一个可编程的分频器。输入一个较高的系统时钟(例如50 MHz),通过一个分频系数(DIV)产生所需的波特率时钟。

- 计算公式:
Baud Rate = f clk 16 × DIV \text{Baud Rate} = \frac{f_{\text{clk}}}{16 \times \text{DIV}} Baud Rate=16×DIVfclk

其中 f clk f_{\text{clk}} fclk是UART模块的输入时钟频率,DIV是存储在寄存器中的分频值。系数16表示为了进行可靠采样,接收器会用16倍波特率的时钟对每个位进行采样。

"手搓串口"的本质 :用GPIO(通用输入输出)引脚配合精确的软件定时器(或硬件定时器),在软件层面模拟上述UART发送器和接收器的时序行为。或者在FPGA中用硬件描述语言(Verilog/VHDL)实现一个完整的UART模块。

5. 电平标准:TTL vs RS-232

这是导致"电脑无法直接连接单片机"的根源。同样的协议(起始位、数据位等),但物理层电压不同。

i. TTL串口(UART电平)

- 逻辑0: 0 V 0V 0V

- 逻辑1: V CC V_{\text{CC}} VCC(通常为 + 3.3 V +3.3V +3.3V或 + 5 V +5V +5V)

- 常见于:MCUArduinoSTM32开发板。

ii. RS-232串口

- 逻辑0SPACE): + 3 V +3V +3V到 + 15 V +15V +15V

- 逻辑1MARK): − 3 V -3V −3V到 − 15 V -15V −15V

- 常见于:旧式电脑DB9接口、工业设备。

连接问题 :将MCU3.3V``TTL信号直接接到RS-232接口上,不仅无法被正确识别(RS-232认为 0 V 0V 0V- 3 V 3V 3V是无效电平),还可能因电压不匹配损坏MCU。因此需要电平转换芯片 ,如**MAX232(转换 + 5 V +5V +5VTTLRS-232)或 MAX3232(转换 + 3.3 V +3.3V +3.3VTTLRS-232)。现在更常用的方法是使用 USBTTL串口模块**,直接绕过了RS-232电平。

四、实践指南:如何"手搓"一个简易串口

1. 波特率定时器计算示例

假设使用72 MHz的系统时钟,要产生115200波特率:

根据公式:
DIV = f clk 16 × Baud Rate = 72 , 000 , 000 16 × 115200 = 72 , 000 , 000 1 , 843 , 200 ≈ 39.0625 \text{DIV} = \frac{f_{\text{clk}}}{16 \times \text{Baud Rate}} = \frac{72,000,000}{16 \times 115200} = \frac{72,000,000}{1,843,200} \approx 39.0625 DIV=16×Baud Ratefclk=16×11520072,000,000=1,843,20072,000,000≈39.0625

实际中DIV必须为整数,因此通常使用附近的整数39,或使用更高精度的分数分频器。

2. 软件模拟串口发送示例

a. 引脚定义

c 复制代码
/* ============================================================================
 *CN:UART引脚定义--EN:UART Pin Definitions
 * ============================================================================
 */
sbit UART_TX = P2;      /*CN:发送引脚,对应杰克控制开关的那只手--EN:Transmit pin, corresponds to Jack controlling the switch*/
sbit UART_RX = P3;      /*CN:接收引脚,对应露丝感受电流的电磁铁--EN:Receive pin, corresponds to Rose's electromagnet feeling the current*/

b. 初始化

对照协议:

UART 协议规定,通信线路在没有任何数据传输时,必须处于一个确定的"空闲"状态。对于 TTL 电平的 UART,空闲状态被定义为高电平 (逻辑 1)。

这个高电平就像杰克始终拉紧的绳子,意味着"一切安好,但暂无消息"。

初始化阶段要完成两件事:

i. 配置发送引脚P2(TX)为输出模式 :让单片机具备主动拉高或拉低这条线的能力,也就是让杰克能够控制开关的通断。

ii. P2(TX)引脚设置为高电平 :主动进入空闲状态。此时,线路上的电压为 VCC(如 +3.3V+5V),接收方(另一块单片机的 RX 引脚)会读取到这个高电平,并进入 while(RX == 1); 的监听循环,静静地等待第一个下降沿的到来。

如果初始化时忘记将 P2(TX)拉高,或者将其错误地配置为低电平,接收方可能会误认为有一个"起始位"正在持续,从而产生一帧错误的乱码数据。

c 复制代码
/**
 * @brief CN:初始化UART--EN:Initialize UART
 *
 * CN:配置UART发送引脚为输出并设置默认高电平(阶段一:空闲状态)
 *    UART_RX引脚默认为输入,无需额外配置
 *
 * @param[in]  none
 *
 * @return     CN:无--EN:None
 */
void Uart_Init(void)
{
    /*CN:配置P2(TX)为输出,P3(RX)默认输入无需配置--EN:Set P2(TX) as output, P3(RX) input by default*/
    P2CR &= ~BIT2;       /*CN:P2配置为输出(具体寄存器名视芯片而定)--EN:P2 configured as output (specific register name depends on chip)*/

    /*
     *CN:阶段一【空闲状态】
     *CN:P2(TX)输出高电平,线路持续高电平
     *CN:发送方没发数据,接收方在while(UART_RX == 1);死等
     */
    UART_TX = 1;
}

c. 发送一个字节(概念回顾与逐阶段对照)

发送字节的过程,本质上是杰克按照约定的时序,通过控制开关的通断,在导线上制造出一系列符合 UART 协议的电平变化。接收方(露丝)则根据这些变化,还原出原始数据。

阶段一:【空闲状态】

协议标准:

线路在空闲时保持高电平(逻辑 1)。发送方不动作,接收方持续监测 RX 引脚,等待一个从高到低的跳变。

发送方(P2 接收方(P3
UART_TX = 1; while (UART_RX == 1);
保持高电平,不发数据 死等,直到检测到下降沿

发送方代码(在初始化中已完成):

c 复制代码
UART_TX = 1;   /* CN:线路保持高电平,空闲状态 -- EN:Line stays high, idle state */
阶段二:【发送起始位------产生下降沿】

协议标准:

发送方将 TX 线从高电平拉低,持续一个完整的位周期。这个从高到低的跳变(下降沿)是唤醒接收方的信号,标志着一帧数据的开始。

发送方(P2 接收方(P3
UART_TX = 0; 拉低引脚,产生下降沿 检测到下降沿,跳出 while 循环,被唤醒

发送方代码

c 复制代码
UART_TX = 0;        /* CN:产生下降沿,唤醒接收方 -- EN:Generate falling edge, wake up receiver */
阶段三:【起始位持续------保持低电平】

协议标准:

起始位总共占据一个完整的位周期。发送方需要保持 TX 线为低电平,直到起始位结束。接收方在这一阶段等待半个位周期,确认这是真正的起始位而非干扰毛刺。

发送方(P2 接收方(P3
NOP(); 保持低电平(前半周期) NOP(); 等半个位周期,确认起始位有效
NOP(); 保持低电平(后半周期) NOP(); 再等半个位周期,跨过起始位,对准第一个数据位中心

发送方代码

c 复制代码
NOP();   /* CN:起始位前半周期,保持低电平 -- EN:First half of start bit, keep low */
NOP();   /* CN:起始位后半周期,起始位结束 -- EN:Second half of start bit, start bit ends */

接收方视角(理解为何要等):

c 复制代码
NOP();   /* CN:等半个周期,确认这不是毛刺 -- EN:Wait half period, confirm it's not a glitch */
NOP();   /* CN:再等半个周期,跨过起始位,对准第0位中心 -- EN:Wait another half period, skip start bit, align to bit0 center */
阶段四:【发送数据位------逐位输出】

协议标准:

数据位按照 LSB(最低有效位)先发的顺序逐个到来,每一位占据一个完整位周期。发送方在每位开始时根据该位的值(01)设置 TX 引脚电平,并在整个位周期内保持稳定。接收方在每个位周期的正中间采样,因为此时信号最稳定。

发送方(P2 接收方(P3
根据当前位值设置 UART_TX (稍后,在每位正中间采样)
NOP(); 前半周期,电平稳定 NOP(); 对准中心,采样 UART_RX
NOP(); 后半周期,准备下一位 NOP(); 再等半个周期,对准下一位中心

发送方代码(以8位数据为例,LSB先发)

c 复制代码
/* 第零位(LSB)*/
if (data & 0x01)    UART_TX = 1;   /* CN:位为1,拉高(松开开关,无电流)-- EN:Bit 1, pull high */
else                UART_TX = 0;   /* CN:位为0,拉低(闭合开关,有电流)-- EN:Bit 0, pull low */
NOP(); NOP();       /* CN:保持一个完整位周期 -- EN:Hold for one full bit period */

/* 第一位 */
if (data & 0x02)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第二位 */
if (data & 0x04)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第三位 */
if (data & 0x08)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第四位 */
if (data & 0x10)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第五位 */
if (data & 0x20)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第六位 */
if (data & 0x40)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第七位(MSB)*/
if (data & 0x80)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();
阶段五:【发送停止位------收官与校验】

协议标准:

所有数据位发送完毕后,发送方必须将 TX 线拉回高电平,并保持至少一个位周期。这就是停止位。接收方在停止位的正中间采样 RX 线,确认是高电平------说明这一帧传输有效;如果采样到低电平,则说明出现帧错误(Frame Error)。

发送方(P2 接收方(P3
UART_TX = 1; 拉高引脚 NOP(); 等待,对准停止位中心
NOP(); 前半周期 NOP(); 后半周期,采样,校验是否为高电平
NOP(); 后半周期,停止位结束 ---

发送方代码

c 复制代码
UART_TX = 1;        /* CN:拉高引脚,进入停止位(重新拉紧绳子)-- EN:Pull high, enter stop bit */
NOP(); NOP();       /* CN:保持一个完整位周期,停止位结束 -- EN:Hold one full bit period, stop bit ends */

接收方代码(理解为何要校验):

c 复制代码
NOP(); NOP();       /* CN:等待,对准停止位中心 -- EN:Wait, align to stop bit center */
if (UART_RX == 0)   /* CN:如果采样到低电平 -- EN:If sampled low */
{
    frame_error = 1;   /* CN:帧错误! -- EN:Frame error! */
}
回到阶段一:【空闲状态】

协议标准:

停止位结束后,线路恢复持续高电平。发送方回到空闲状态,准备发送下一个字节;接收方也回到监听状态,等待下一个起始位的下降沿。

发送方(P2 接收方(P3
UART_TX = 1;(已经是高电平) while (UART_RX == 1); 继续死等
线路持续高电平 等待下一帧的下降沿

发送方代码

c 复制代码
/* CN:P2已经是1,线路持续高电平 -- EN:P2 is already high, line stays high */
/* CN:可以开始发送下一个字节 -- EN:Ready to send next byte */

对照故事:

杰克重新拉紧绳子,等待下一次敲击键盘。露丝的电磁铁恢复吸合,棘轮复位,所有机构回到原点,等待着下一个起始位带来的惊喜。

发送字节完整时序图
复制代码
发送方 P2(TX):
 空闲     起始位      第0位      第1位      第2位      第3位      第4位      第5位      第6位      第7位      停止位      空闲
 ────┐   ┌────┐     ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌────┐   ┌────
     │   │    │     │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │    │   │
     └───┘    └─────┘  └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └────┘   └────
         ↓         ↓      ↓      ↓      ↓      ↓      ↓      ↓      ↓      ↓      ↓
       空闲      起始位   第0位  第1位  第2位  第3位  第4位  第5位  第6位  第7位  停止位
       (高)      (低)    采样点 采样点 采样点 采样点 采样点 采样点 采样点 采样点 采样点
                       (中间) (中间) (中间) (中间) (中间) (中间) (中间) (中间) (校验)
发送方完整代码示例
c 复制代码
/**
 * @brief CN:通过UART发送一个字节 -- EN:Send a byte via UART
 *
 * CN:以软件模拟方式发送一字节数据,格式8N1
 *    用 NOP() 延时,两个 NOP() 为一个位周期,一个 NOP() 为半个位周期
 *
 * @param[in]  data  CN:要发送的数据 -- EN:Data byte to send
 *
 * @return     CN:无 -- EN:None
 *
 * @note       CN:发送期间关中断以保证时序 -- EN:Interrupts disabled during transmission for timing integrity
 */
void Uart_Send(uint8_t data)
{
    /* CN:关中断,保证时序不被干扰 -- EN:Disable interrupts for timing integrity */
    DISI();

    /* ====================================================================
     * 阶段二、三:起始位(1 个完整位周期 = 两个 NOP())
     * ====================================================================
     */
    UART_TX = 0;        /* 阶段二【下降沿】从高拉低,产生下降沿,唤醒接收方 */
    NOP();              /* 阶段三【起始位前半段】保持低电平 */
    NOP();              /* 阶段三【起始位后半段】保持低电平,起始位结束 */

    /* ====================================================================
     * 阶段四:数据位(8 位,LSB 先发)
     * 每一位占一个完整位周期 = 两个 NOP()
     * ====================================================================
     */

    /* 第零位(LSB) */
    if (data & 0x01)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第一位 */
    if (data & 0x02)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第二位 */
    if (data & 0x04)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第三位 */
    if (data & 0x08)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第四位 */
    if (data & 0x10)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第五位 */
    if (data & 0x20)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第六位 */
    if (data & 0x40)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第七位(MSB) */
    if (data & 0x80)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* ====================================================================
     * 阶段五:停止位(1 个完整位周期 = 两个 NOP())
     * ====================================================================
     */
    UART_TX = 1;        /* 拉高引脚,进入停止位 */
    NOP();              /* 停止位前半周期 */
    NOP();              /* 停止位后半周期,结束 */

    /* ====================================================================
     * 回到阶段一:空闲状态
     * ====================================================================
     */
    /* CN:恢复中断 -- EN:Restore interrupts */
    ENI();
}
发送方与接收方完整对照总表
阶段 线路状态 发送方(P2)代码 接收方(P3)代码 杰克 露丝
持续高电平 UART_TX = 1; while (UART_RX == 1); 拉紧绳子 电磁铁吸合,死等
高→低跳变 UART_TX = 0; 跳出while循环 松开绳子 咔嗒!醒了
低电平 第一个NOP(); 第一个NOP(); 保持松开 等半拍,确认不是误报
低电平 第二个NOP(); 第二个NOP(); 保持松开 跳过起始位,对准第零位
变化中 逐位翻转+两个NOP();×8 逐位采样+两个NOP();×8 按编码松拉绳子 棘轮逐拍凿孔
停止 高电平 UART_TX = 1;+两个NOP(); 两个NOP();+校验 重新拉紧绳子 电磁铁吸合,校验有效
持续高电平 P2保持1 继续while (UART_RX == 1); 拉紧等待 电磁铁吸合,等下一帧

d. 接收一个字节(概念回顾与逐阶段对照)

阶段一:【空闲监听】

协议标准:线路在空闲时保持高电平(逻辑1)。发送方不动作,接收方持续监测P3(RX)引脚,等待一个从高到低的跳变。这个跳变标志着起始位的开始,也宣告一帧数据的到来。

发送方(P2 接收方(P3
UART_TX = 1; while (UART_RX == 1);
保持高电平,不发数据 死等,直到P3变低

发送方(在初始化中体现):

c 复制代码
UART_TX = 1;

接收方

c 复制代码
while (UART_RX == 1);   /*CN:死等,直到P3变低------检测到下降沿!--EN:Wait until P3 goes low - detected falling edge!*/
阶段二:【下降沿检测】
发送方(P2 接收方(P3
UART_TX = 0; 拉低引脚,产生下降沿 跳出while循环,被唤醒

发送方

c 复制代码
UART_TX = 0;        /*CN:产生下降沿,唤醒接收方--EN:Generate falling edge, wake up receiver*/

接收方 (在while循环处自动跳出):

c 复制代码
/*CN:因为while (UART_RX == 1);已经跳出,代码继续向下执行--EN:Because while (UART_RX == 1); has exited, code continues to execute*/
阶段三:【起始位前半段------确认有效】
发送方(P2 接收方(P3
第一个NOP();保持低电平 第一个NOP();等半个位周期,确认起始位有效

发送方

c 复制代码
NOP();  /*CN:低电平持续半个周期--EN:Low level continues for half period*/

接收方

c 复制代码
NOP();  /*CN:等半个位周期,确认起始位有效--EN:Wait half bit period, confirm start bit is valid*/
阶段四:【起始位后半段------跳过起始位】
发送方(P2 接收方(P3
第二个NOP();继续低电平,起始位结束 第二个NOP();跨过起始位,对准第零位中心

发送方

c 复制代码
NOP();  /*CN:起始位结束--EN:Start bit ends*/

接收方

c 复制代码
NOP();  /*CN:跳过起始位,对准第一个数据位中心--EN:Skip start bit, align to the center of first data bit*/
阶段五:【数据位------中间采样】
发送方(P2 接收方(P3
逐位翻转P2+两个NOP() 逐位采样P3+两个NOP()

发送方(示例第零位)

c 复制代码
if (data & 0x01)    UART_TX = 1;
else                UART_TX = 0;
NOP();NOP();

接收方(示例第零位)

c 复制代码
if (UART_RX == 1)  received_data |= 0x01;
NOP();NOP();
停止位:【帧校验】
发送方(P2 接收方(P3
UART_TX = 1;+两个NOP() 两个NOP()+校验P3是否为1

发送方

c 复制代码
UART_TX = 1;
NOP();NOP();

接收方

c 复制代码
NOP();NOP();
if (UART_RX == 0) frame_error = 1;
回到阶段一:【空闲】
发送方(P2 接收方(P3
P2保持1,回到空闲 P3恢复高,继续while (UART_RX == 1);

发送方

c 复制代码
/*CN:P2已经为1,无需额外操作--EN:P2 is already 1, no extra operation needed*/

接收方

c 复制代码
/*CN:回到while循环,等待下一帧--EN:Return to while loop, wait for next frame*/
一帧完整传输的时间轴(发送方与接收方同步对照)
复制代码
发送方 P2:
 空闲 │起始位│  第0位 │  第1位 │  第2位 │  第3位 │  第4位 │  第5位 │  第6位 │  第7位 │停止位│ 空闲
 ────┐┌────┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌────┐┌────
     ││    ││  │  ││  │  ││  │  ││  │  ││  │  ││  │  ││  │  ││  │  ││    ││
     ┘┘    ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘    ┘┘

接收方 P3:
 空闲 │起始位│  第0位 │  第1位 │  第2位 │  第3位 │  第4位 │  第5位 │  第6位 │  第7位 │停止位│ 空闲
 ────┐┌────┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌──┬──┐┌────┐┌────
     ││    ││  │  ││  │  ││  │  ││  │  ││  │  ││  │  ││  │  ││  │  ││    ││
     ┘┘    ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘  ┘  ┘┘    ┘┘
         ↑              ↑      ↑      ↑      ↑      ↑      ↑      ↑      ↑      ↑
      起始位         第0位   第1位   第2位   第3位   第4位   第5位   第6位   第7位  停止位
      下降沿         采样点  采样点  采样点  采样点  采样点  采样点  采样点  采样点  采样点
      (唤醒)        (中间)  (中间)  (中间)  (中间)  (中间)  (中间)  (中间)  (中间)  (校验)

阶段:   一     二   三   四      五      五      五      五      五      五      五      五     停止     一

e. 发送一个字节(概念回顾与逐阶段对照)

发送字节的过程,本质上是杰克按照约定的时序,通过控制开关的通断,在导线上制造出一系列符合 UART 协议的电平变化。接收方(露丝)则根据这些变化,还原出原始数据。

阶段一:【空闲状态】

协议标准:

线路在空闲时保持高电平(逻辑 1)。发送方不动作,接收方持续监测 RX 引脚,等待一个从高到低的跳变。

发送方(P2 接收方(P3
UART_TX = 1; while (UART_RX == 1);
保持高电平,不发数据 死等,直到检测到下降沿

发送方代码(在初始化中已完成):

c 复制代码
UART_TX = 1;   /* CN:线路保持高电平,空闲状态 -- EN:Line stays high, idle state */
阶段二:【发送起始位------产生下降沿】

协议标准:

发送方将 TX 线从高电平拉低,持续一个完整的位周期。这个从高到低的跳变(下降沿)是唤醒接收方的信号,标志着一帧数据的开始。

发送方(P2 接收方(P3
UART_TX = 0; 拉低引脚,产生下降沿 检测到下降沿,跳出 while 循环,被唤醒

发送方代码

c 复制代码
UART_TX = 0;        /* CN:产生下降沿,唤醒接收方 -- EN:Generate falling edge, wake up receiver */
阶段三:【起始位持续------保持低电平】

协议标准:

起始位总共占据一个完整的位周期。发送方需要保持 TX 线为低电平,直到起始位结束。接收方在这一阶段等待半个位周期,确认这是真正的起始位而非干扰毛刺。

发送方(P2 接收方(P3
NOP(); 保持低电平(前半周期) NOP(); 等半个位周期,确认起始位有效
NOP(); 保持低电平(后半周期) NOP(); 再等半个位周期,跨过起始位,对准第一个数据位中心

发送方代码

c 复制代码
NOP();   /* CN:起始位前半周期,保持低电平 -- EN:First half of start bit, keep low */
NOP();   /* CN:起始位后半周期,起始位结束 -- EN:Second half of start bit, start bit ends */

接收方视角(理解为何要等):

c 复制代码
NOP();   /* CN:等半个周期,确认这不是毛刺 -- EN:Wait half period, confirm it's not a glitch */
NOP();   /* CN:再等半个周期,跨过起始位,对准第0位中心 -- EN:Wait another half period, skip start bit, align to bit0 center */
阶段四:【发送数据位------逐位输出】

协议标准:

数据位按照 LSB(最低有效位)先发的顺序逐个到来,每一位占据一个完整位周期。发送方在每位开始时根据该位的值(01)设置 TX 引脚电平,并在整个位周期内保持稳定。接收方在每个位周期的正中间采样,因为此时信号最稳定。

发送方(P2 接收方(P3
根据当前位值设置 UART_TX (稍后,在每位正中间采样)
NOP(); 前半周期,电平稳定 NOP(); 对准中心,采样 UART_RX
NOP(); 后半周期,准备下一位 NOP(); 再等半个周期,对准下一位中心

发送方代码(以8位数据为例,LSB先发)

c 复制代码
/* 第零位(LSB)*/
if (data & 0x01)    UART_TX = 1;   /* CN:位为1,拉高(松开开关,无电流)-- EN:Bit 1, pull high */
else                UART_TX = 0;   /* CN:位为0,拉低(闭合开关,有电流)-- EN:Bit 0, pull low */
NOP(); NOP();       /* CN:保持一个完整位周期 -- EN:Hold for one full bit period */

/* 第一位 */
if (data & 0x02)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第二位 */
if (data & 0x04)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第三位 */
if (data & 0x08)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第四位 */
if (data & 0x10)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第五位 */
if (data & 0x20)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第六位 */
if (data & 0x40)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();

/* 第七位(MSB)*/
if (data & 0x80)    UART_TX = 1;
else                UART_TX = 0;
NOP(); NOP();
阶段五:【发送停止位------收官与校验】

协议标准:

所有数据位发送完毕后,发送方必须将 TX 线拉回高电平,并保持至少一个位周期。这就是停止位。接收方在停止位的正中间采样 RX 线,确认是高电平------说明这一帧传输有效;如果采样到低电平,则说明出现帧错误(Frame Error)。

发送方(P2 接收方(P3
UART_TX = 1; 拉高引脚 NOP(); 等待,对准停止位中心
NOP(); 前半周期 NOP(); 后半周期,采样,校验是否为高电平
NOP(); 后半周期,停止位结束 ---

发送方代码

c 复制代码
UART_TX = 1;        /* CN:拉高引脚,进入停止位(重新拉紧绳子)-- EN:Pull high, enter stop bit */
NOP(); NOP();       /* CN:保持一个完整位周期,停止位结束 -- EN:Hold one full bit period, stop bit ends */

接收方代码(理解为何要校验):

c 复制代码
NOP(); NOP();       /* CN:等待,对准停止位中心 -- EN:Wait, align to stop bit center */
if (UART_RX == 0)   /* CN:如果采样到低电平 -- EN:If sampled low */
{
    frame_error = 1;   /* CN:帧错误! -- EN:Frame error! */
}
回到阶段一:【空闲状态】

协议标准:

停止位结束后,线路恢复持续高电平。发送方回到空闲状态,准备发送下一个字节;接收方也回到监听状态,等待下一个起始位的下降沿。

发送方(P2 接收方(P3
UART_TX = 1;(已经是高电平) while (UART_RX == 1); 继续死等
线路持续高电平 等待下一帧的下降沿

发送方代码

c 复制代码
/* CN:P2已经是1,线路持续高电平 -- EN:P2 is already high, line stays high */
/* CN:可以开始发送下一个字节 -- EN:Ready to send next byte */

对照故事:

杰克重新拉紧绳子,等待下一次敲击键盘。露丝的电磁铁恢复吸合,棘轮复位,所有机构回到原点,等待着下一个起始位带来的惊喜。

发送字节完整时序图
复制代码
发送方 P2(TX):
 空闲     起始位      第0位      第1位      第2位      第3位      第4位      第5位      第6位      第7位      停止位      空闲
 ────┐   ┌────┐     ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌──┬──┐   ┌────┐   ┌────
     │   │    │     │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │  │  │   │    │   │
     └───┘    └─────┘  └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └──┘   └────┘   └────
         ↓         ↓      ↓      ↓      ↓      ↓      ↓      ↓      ↓      ↓      ↓
       空闲      起始位   第0位  第1位  第2位  第3位  第4位  第5位  第6位  第7位  停止位
       (高)      (低)    采样点 采样点 采样点 采样点 采样点 采样点 采样点 采样点 采样点
                       (中间) (中间) (中间) (中间) (中间) (中间) (中间) (中间) (校验)
发送方完整代码示例
c 复制代码
/**
 * @brief CN:通过UART发送一个字节 -- EN:Send a byte via UART
 *
 * CN:以软件模拟方式发送一字节数据,格式8N1
 *    用 NOP() 延时,两个 NOP() 为一个位周期,一个 NOP() 为半个位周期
 *
 * @param[in]  data  CN:要发送的数据 -- EN:Data byte to send
 *
 * @return     CN:无 -- EN:None
 *
 * @note       CN:发送期间关中断以保证时序 -- EN:Interrupts disabled during transmission for timing integrity
 */
void Uart_Send(uint8_t data)
{
    /* CN:关中断,保证时序不被干扰 -- EN:Disable interrupts for timing integrity */
    DISI();

    /* ====================================================================
     * 阶段二、三:起始位(1 个完整位周期 = 两个 NOP())
     * ====================================================================
     */
    UART_TX = 0;        /* 阶段二【下降沿】从高拉低,产生下降沿,唤醒接收方 */
    NOP();              /* 阶段三【起始位前半段】保持低电平 */
    NOP();              /* 阶段三【起始位后半段】保持低电平,起始位结束 */

    /* ====================================================================
     * 阶段四:数据位(8 位,LSB 先发)
     * 每一位占一个完整位周期 = 两个 NOP()
     * ====================================================================
     */

    /* 第零位(LSB) */
    if (data & 0x01)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第一位 */
    if (data & 0x02)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第二位 */
    if (data & 0x04)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第三位 */
    if (data & 0x08)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第四位 */
    if (data & 0x10)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第五位 */
    if (data & 0x20)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第六位 */
    if (data & 0x40)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* 第七位(MSB) */
    if (data & 0x80)    UART_TX = 1;
    else                UART_TX = 0;
    NOP(); NOP();

    /* ====================================================================
     * 阶段五:停止位(1 个完整位周期 = 两个 NOP())
     * ====================================================================
     */
    UART_TX = 1;        /* 拉高引脚,进入停止位 */
    NOP();              /* 停止位前半周期 */
    NOP();              /* 停止位后半周期,结束 */

    /* ====================================================================
     * 回到阶段一:空闲状态
     * ====================================================================
     */
    /* CN:恢复中断 -- EN:Restore interrupts */
    ENI();
}
发送方与接收方完整对照总表
阶段 线路状态 发送方(P2)代码 接收方(P3)代码 杰克 露丝
持续高电平 UART_TX = 1; while (UART_RX == 1); 拉紧绳子 电磁铁吸合,死等
高→低跳变 UART_TX = 0; 跳出while循环 松开绳子 咔嗒!醒了
低电平 第一个NOP(); 第一个NOP(); 保持松开 等半拍,确认不是误报
低电平 第二个NOP(); 第二个NOP(); 保持松开 跳过起始位,对准第零位
变化中 逐位翻转+两个NOP();×8 逐位采样+两个NOP();×8 按编码松拉绳子 棘轮逐拍凿孔
停止 高电平 UART_TX = 1;+两个NOP(); 两个NOP();+校验 重新拉紧绳子 电磁铁吸合,校验有效
持续高电平 P2保持1 继续while (UART_RX == 1); 拉紧等待 电磁铁吸合,等下一帧

h. 完整代码(接收+发送)

c 复制代码
/* ============================================================================
 *CN:UART引脚定义--EN:UART Pin Definitions
 * ============================================================================
 */
sbit UART_TX = P2^0;      /*CN:发送引脚,对应杰克控制开关的那只手--EN:Transmit pin, corresponds to Jack controlling the switch*/
sbit UART_RX = P3^1;      /*CN:接收引脚,对应露丝感受电流的电磁铁--EN:Receive pin, corresponds to Rose's electromagnet feeling the current*/

/* ============================================================================
 *CN:UART初始化--EN:UART Initialization
 * ============================================================================

/**
 * @brief CN:初始化UART--EN:Initialize UART
 *
 * CN:配置UART发送引脚为输出并设置默认高电平(阶段一:空闲状态)
 *    UART_RX引脚默认为输入,无需额外配置
 *
 * @param[in]  none
 *
 * @return     CN:无--EN:None
 */
void Uart_Init(void)
{
    /*CN:配置P2(TX)为输出,P3(RX)默认输入无需配置--EN:Set P2(TX) as output, P3(RX) input by default*/
    P2CR &= ~BIT2;       /*CN:P2配置为输出(具体寄存器名视芯片而定)--EN:P2 configured as output (specific register name depends on chip)*/

    /*CN:阶段一【空闲状态】P2(TX)输出高电平--EN:Stage 1 [Idle] P2(TX) outputs high level*/
    UART_TX = 1;
}

/* ============================================================================
 *CN:UART发送一个字节--EN:UART Send One Byte
 * ============================================================================

/**
 * @brief CN:通过UART发送一个字节--EN:Send a byte via UART
 *
 * CN:以软件模拟方式发送一字节数据,格式8N1
 *    用NOP()延时,两个NOP()为一个位周期,一个NOP()为半个位周期
 *
 * @param[in]  data  CN:要发送的数据--EN:Data byte to send
 *
 * @return     CN:无--EN:None
 *
 * @note       CN:发送期间关中断以保证时序--EN:Interrupts disabled during transmission for timing integrity
 */
void Uart_Send(uint8_t data)
{
    /*CN:关中断,保证时序不被干扰--EN:Disable interrupts for timing integrity*/
    DISI();

    /*CN:起始位--EN:Start bit*/
    UART_TX = 0;
    NOP();NOP();

    /*CN:数据位:LSB先发--EN:Data bits: LSB first*/
    /*CN:第零位--EN:Bit 0*/
    if (data & 0x01)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第一位--EN:Bit 1*/
    if (data & 0x02)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第二位--EN:Bit 2*/
    if (data & 0x04)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第三位--EN:Bit 3*/
    if (data & 0x08)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第四位--EN:Bit 4*/
    if (data & 0x10)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第五位--EN:Bit 5*/
    if (data & 0x20)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第六位--EN:Bit 6*/
    if (data & 0x40)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:第七位(MSB)--EN:Bit 7 (MSB)*/
    if (data & 0x80)    UART_TX = 1;
    else                UART_TX = 0;
    NOP();NOP();

    /*CN:停止位--EN:Stop bit*/
    UART_TX = 1;
    NOP();NOP();

    /*CN:恢复中断--EN:Restore interrupts*/
    ENI();
}

/* ============================================================================
 *CN:UART接收一个字节--EN:UART Receive One Byte
 * ============================================================================

/**
 * @brief CN:通过UART接收一个字节--EN:Receive a byte via UART
 *
 * CN:以软件模拟方式接收一字节数据,格式8N1
 *    用NOP()延时,两个NOP()为一个位周期,一个NOP()为半个位周期
 *
 * @param[in]  none
 *
 * @return     CN:收到的数据字节--EN:Received data byte
 *
 * @note       CN:接收期间关中断以保证时序--EN:Interrupts disabled during reception for timing integrity
 */
uint8_t Uart_Receive(void)
{
    uint8_t received_data = 0;

    /*CN:关中断,保证时序不被干扰--EN:Disable interrupts for timing integrity*/
    DISI();

    /*CN:阶段一:空闲监听,等待下降沿--EN:Stage 1: Idle monitoring, wait for falling edge*/
    while (UART_RX == 1);

    /*CN:阶段二、三:跳过起始位,对准第一个数据位中心--EN:Stage 2,3: Skip start bit, align to center of first data bit*/
    NOP();NOP();

    /*CN:阶段四:数据位采样(LSB先收)--EN:Stage 4: Data bit sampling (LSB first)*/
    /*CN:第零位--EN:Bit 0*/
    if (UART_RX == 1)  received_data |= 0x01;
    NOP();NOP();

    /*CN:第一位--EN:Bit 1*/
    if (UART_RX == 1)  received_data |= 0x02;
    NOP();NOP();

    /*CN:第二位--EN:Bit 2*/
    if (UART_RX == 1)  received_data |= 0x04;
    NOP();NOP();

    /*CN:第三位--EN:Bit 3*/
    if (UART_RX == 1)  received_data |= 0x08;
    NOP();NOP();

    /*CN:第四位--EN:Bit 4*/
    if (UART_RX == 1)  received_data |= 0x10;
    NOP();NOP();

    /*CN:第五位--EN:Bit 5*/
    if (UART_RX == 1)  received_data |= 0x20;
    NOP();NOP();

    /*CN:第六位--EN:Bit 6*/
    if (UART_RX == 1)  received_data |= 0x40;
    NOP();NOP();

    /*CN:第七位(MSB)--EN:Bit 7 (MSB)*/
    if (UART_RX == 1)  received_data |= 0x80;
    NOP();NOP();

    /*CN:停止位校验(此处简化,未做错误处理)--EN:Stop bit check (simplified, no error handling)*/
    NOP();NOP();

    /*CN:恢复中断--EN:Restore interrupts*/
    ENI();

    return received_data;
}

五、串口的衍生:RS-485

RS-485作为RS-232的升级标准,在工业自动化、远程数据采集等领域得到了广泛应用。理解其工作原理和接口规范,是正确设计和部署RS-485通信网络的基础。

1. 工作原理

(1) 差分信号传输

RS-485是一种定义UART串行通信系统中使用的驱动器和接收器电气特性的标准,采用平衡发送差分接收 的结构设计。通过一对双绞线(通常标记为A线和B线),利用两条线上电压的差值来表示逻辑信号。

具体逻辑电平定义如下:

i. 逻辑1 :两线间的电压差为 + 2 V +2V +2V至 + 6 V +6V +6V( V A − V B > + 200 m V V_A - V_B > +200mV VA−VB>+200mV)

ii. 逻辑0 :两线间的电压差为 − 6 V -6V −6V至 − 2 V -2V −2V( V A − V B < − 200 m V V_A - V_B < -200mV VA−VB<−200mV)

接收端通过检测这个电压差来判定逻辑状态,其差分输入阈值电压典型值为 ± 200 m V \pm 200mV ±200mV。这种差分传输方式能够有效抑制共模干扰,使得RS-485具有强大的抗噪能力和远距离传输能力。

(2) 半双工工作方式与总线控制

RS-485通常采用半双工 工作方式,意味着数据可以在两个方向上传输,但同一时间只能有一个节点处于发送状态。因此,发送电路须由使能信号加以控制。

收发器上通常有以下控制引脚:

a. DEDriver Enable :驱动器使能引脚。当DE = 1时,驱动器处于发送状态。

b. /REReceiver Enable :接收器使能引脚(低电平有效)。当/RE = 0时,接收器处于接收状态。

在多点网络中,通常采用主从通信方式:一个主机控制总线访问权并发起通信请求,多个从机进行响应。

(3) 负载能力与节点扩展

RS-485标准规定总线最多可连接**32个单位负载(UL)**的设备。通过使用输入阻抗更高的接收器(如¼ UL⅛ UL),可连接的节点数可扩展至128个甚至更多。

(4) 终端电阻与信号完整性

为确保信号完整性,在总线电缆的起始端和末端 需要并接终端电阻(通常为 120 Ω 120\Omega 120Ω),以抑制信号反射。电阻缺失会导致通信不稳定;多设备启用电阻会使总线负载过重,导致通信失败。

(5) 故障安全功能

部分新型收发器具备**"真故障安全"**功能,通过调整接收器的差分输入阈值电压(如至 − 200 m V -200mV −200mV和 − 30 m V -30mV −30mV之间),确保在总线空闲时,接收器输出一个确定的高电平状态,从而无需外加上拉和下拉电阻。

2. 接口与连接规范

(1) 物理连接器与引脚定义

RS-485接口的物理连接通常使用**DB-9型连接器**。对于常见的两线制半双工连接,其引脚定义存在多种标识习惯,实际应用中两线一般定义为ABData+Data-,即常说的485+485-

标识习惯 发送/接收正极 发送/接收负极 地线
英式标识 TDB(+)/RDB(+) TDA(-)/RDA(-) GND
美式标识 Y/A Z/B GND
中式标识 TXD(+)/A TXD(-)/B GND

(2) 接线方式

RS-485主要有两线制和四线制两种接线方式:

a. 四线制 :可实现全双工通信,但只能实现点对点的通信方式,现已很少采用。

b. 两线制 :实现半双工总线通信,采用总线式拓扑结构,在同一总线上最多可以挂接多个节点。

在通信网络中一般采用主从通信方式

(3) 布线规范

正确的布线对保证通信可靠性至关重要:

i. 传输介质 :应使用屏蔽双绞线 作为传输介质。

ii. 网络拓扑 :应避免星型连接,优先采用**菊花链(总线型)**结构,将各个节点串接起来。

iii. 分支线长度 :从总线到每个节点的分支线长度应尽量短,以减少信号反射。

iv. 阻抗连续性:总线应具备特性阻抗的连续性。

(4) 终端电阻配置规范

终端电阻的标准值为** 120 Ω 120\Omega 120Ω**,需匹配线缆的特性阻抗。

配置要点:

a. 仅在线路最远端的两端设备 上安装终端电阻,中间设备必须禁用。

b. 当通信线缆较长(如超过 50 50 50米)或传输速率较高(如波特率 ≥ 115.2 kbps \ge 115.2\text{kbps} ≥115.2kbps)时,建议添加终端电阻。

c. 可通过测量总线两端总阻值(正常应接近 60 Ω 60\Omega 60Ω)来验证配置是否正确。

(5) 保护措施

在工业环境中,RS-485接口需要采取多种保护措施:

i. 隔离保护 :不同节点间可能存在地电位差,可采用光电隔离或使用隔离式DC-DC电源及iCoupler等技术进行信号和电源隔离,以消除地环路干扰并保护接口芯片。

ii. 瞬态电压抑制 :为防止雷击、静电放电(ESD)等瞬变电压损坏收发器,可采用**TVS二极管**将总线电压箝位至收发器的共模电压范围( − 7 V -7V −7V至 + 12 V +12V +12V),或选用内置增强ESD保护的芯片型号。

iii. 空闲状态偏置 :在某些场景下,可在总线A线接上拉电阻、B线接下拉电阻,将空闲状态电压拉至确定电平,避免噪声干扰。

3. 主要区别对比(RS-232 vs RS-485

下表总结了RS-232RS-485的核心差异:

特性 RS-232 RS-485
传输方式 单端(非平衡) 差分(平衡)
逻辑1电平 − 3 V ∼ − 15 V -3V \sim -15V −3V∼−15V(负压) V A − V B = + 2 V ∼ + 6 V V_A - V_B = +2V \sim +6V VA−VB=+2V∼+6V
逻辑0电平 + 3 V ∼ + 15 V +3V \sim +15V +3V∼+15V(正压) V A − V B = − 6 V ∼ − 2 V V_A - V_B = -6V \sim -2V VA−VB=−6V∼−2V
传输距离 最长约 15 15 15米 最长约 1200 1200 1200米
通信方式 点对点(全双工可选) 多点总线(半双工为主)
最大节点数 1 1 1发 1 1 1收 32 32 32个(可扩展至 128 128 128以上)
抗干扰能力 较弱
常用场景 电脑COM口、调试接口 工业自动化、Modbus、智能仪表
相关推荐
cn_lyg1 小时前
Linux的入门级常用操作命令
linux·运维·服务器
geneculture2 小时前
《智能通信速分多次传输技术(VDMT)》专利文件的全文汉英双语对照版本
服务器·网络·人工智能·融智学的重要应用·哲学与科学统一性·融智时代(杂志)·人机间性
纽扣6672 小时前
【算法进阶之路】链表进阶:删除、合并、回文与排序全解析
数据结构·算法·链表
就叫飞六吧2 小时前
TOML vs YAML:为什么 Cargo 选择 TOML?
linux·运维·服务器
消失的旧时光-19432 小时前
统一并发模型:线程、Reactor、协程本质是一件事(从线程到协程 · 第6篇·终章)
java·python·算法
IMPYLH2 小时前
Linux 的 test 命令
linux·运维·服务器·chrome·bash
智者知已应修善业2 小时前
【51单片机不用数组动态数码管显示字符和LED流水灯】2023-10-3
c++·经验分享·笔记·算法·51单片机
爱编码的小八嘎2 小时前
C语言完美演绎9-16
c语言
xrui583 小时前
2026实战:深度解析 Gemini 3.1 镜像站函数调用在自动化运维工单中的应用
linux·服务器·网络