鞍山市网站建设_网站建设公司_过渡效果_seo优化
2026/1/17 8:16:20 网站建设 项目流程

从零实现UART发送:一个嵌入式工程师的底层实践课

你有没有过这样的经历?代码烧进去,串口助手打开,满怀期待地等着“Hello World”出现——结果屏幕上全是乱码,或者干脆一片空白。这时候,你会不会下意识地怀疑是不是线接反了?波特率设错了?甚至开始怀疑人生?

别急,这正是我们今天要解决的问题。

在STM32开发中,能用库函数点亮LED只是入门;而能从寄存器层面让串口吐出第一个字节,才算真正踏进了嵌入式系统的大门。本文不讲HAL,不谈CubeMX,我们要做的,是亲手把一个字节,通过GPIO引脚,一比特一比特地“推”出去。

这不是模拟(bit-banging),而是利用MCU内置的UART硬件模块,直接操作寄存器,完成一次完整的串行数据发送。整个过程不依赖任何高级驱动,目标明确:让你看清楚每一个环节背后发生了什么。


为什么非得“从零”写一遍UART?

现在大多数项目都用HAL或LL库,几行MX_USART1_UART_Init()就搞定了。那为什么还要费劲去写寄存器?

因为——
当你遇到启动阶段无法使用RTOS、内存紧张不能加载完整库、甚至Bootloader里需要最简日志输出时,你会需要一段只靠几个寄存器就能打出调试信息的代码

更重要的是,只有亲手配置过时钟、算过波特率、等过TXE标志位,你才会真正理解“异步串行通信”到底是什么意思

这不是复古,这是基本功。


我们要做什么?目标拆解

我们的最终目标很简单:

上电后,通过PA9引脚(USART1_TX),以115200波特率,持续发送字符串"Hello, UART!\r\n"

为实现这个目标,我们需要一步步完成以下任务:

  1. 使能相关外设时钟
  2. 配置TX引脚为复用推挽输出
  3. 计算并设置正确的波特率
  4. 配置UART基本参数(8N1)
  5. 编写轮询方式的字节发送函数
  6. 验证输出:用串口助手看到清晰可读的文字

每一步我们都将直接操作STM32F1系列的寄存器,拒绝封装,拒绝抽象。


第一步:让外设“活过来”——时钟与GPIO初始化

所有外设工作的前提是什么?通电 + 有时钟

在STM32中,“通电”就是使能RCC(Reset and Clock Control)中的对应时钟。没有这一步,哪怕你写了再多寄存器,也是对空气操作。

1.1 时钟使能:先给USART和GPIO供电

// 启用GPIOA和USART1时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; // GPIOA时钟开 RCC->APB2ENR |= RCC_APB2ENR_USART1EN; // USART1时钟开

这里有个细节:USART1挂在APB2总线上,而APB2最高支持72MHz(假设系统时钟已配好)。如果你用的是USART2,则要操作APB1时钟寄存器RCC->APB1ENR

⚠️ 常见坑点:忘了开时钟 → 寄存器写不进去,现象是“配置无效”。

1.2 配置PA9为复用功能推挽输出

PA9是默认的USART1_TX引脚。它不是普通IO,必须工作在“复用推挽输出”模式下,才能由UART硬件接管控制权。

STM32F1的GPIO高8位(PA8~PA15)由CRH寄存器控制:

// 清除PA9原有配置(4位MODE + 4位CNF) GPIOA->CRH &= ~(0xF << (9 - 8)*4); // 设置为:输出速度50MHz(MODE[1:0]=11),复用推挽输出(CNF[1:0]=10) GPIOA->CRH |= (GPIO_CRH_MODE9_1 | GPIO_CRH_MODE9_0); // 50MHz GPIOA->CRH |= (GPIO_CRH_CNF9_1); // 复用推挽
位域含义
MODE9[1:0] = 11最大输出速度50MHz
CNF9[1:0] = 10复用功能,推挽输出

✅ 小贴士:为什么不用CNF9[0]?因为在复用推挽模式下,CNFy[1:0] =10即可,无需额外设置。

至此,物理通道已经准备就绪:PA9可以输出UART信号了。


第二步:让节奏“对上”——精确生成波特率

UART是异步通信,没有共享时钟线。双方只能靠“约定好的速率”来采样数据。如果MCU发得快,PC收得慢,就会错位,导致乱码。

所以,波特率必须足够精确。一般要求误差小于±2%。

2.1 波特率怎么来的?

STM32的UART模块采用16倍过采样机制:每个bit周期内采样16次,取中间第8次作为判决值,抗干扰能力强。

其波特率由以下公式决定:

[
\text{DIV} = \frac{f_{\text{PCLK}}}{16 \times \text{BaudRate}}
]

其中:
- ( f_{\text{PCLK}} ) 是APB总线频率(这里是APB2=72MHz)
- BaudRate 是目标波特率(如115200)

代入数值:

[
\text{DIV} = \frac{72,000,000}{16 \times 115200} \approx 39.0625
]

这个值要拆成整数部分(mantissa)和小数部分(fraction),写入BRR寄存器。

  • 整数部分:39 → 0x27
  • 小数部分:0.0625 × 16 ≈ 1 → 0x1

所以BRR = 0x271

2.2 写入BRR寄存器

void uart_set_baudrate(USART_TypeDef* usart, uint32_t baud) { uint32_t pclk = 72000000; // 应动态获取,此处简化 uint32_t div = (pclk + 8 * baud) / (16 * baud); // 四舍五入 usart->BRR = div; } // 调用 uart_set_baudrate(USART1, 115200);

🔍 技巧说明:(pclk + 8*baud)/(16*baud)实现了四舍五入,比直接截断更准。

你可以手动验证:
72000000 / (16 * 115200) = 39.0625→ 四舍五入后为39,即0x271,正确。


第三步:启动UART发动机——配置与使能

现在硬件通道有了,节奏也定了,接下来就是“点火”。

3.1 配置UART参数:8数据位,1停止位,无校验(8N1)

这些参数其实都在一个寄存器里搞定:USART1->CR1

// 配置CR1:启用发送,8N1模式(默认就是8N1) USART1->CR1 = 0; // 先清零 USART1->CR1 |= USART_CR1_TE; // 使能发送 USART1->CR1 |= USART_CR1_UE; // 使能USART

就这么简单?没错。

因为STM32复位后默认就是8位数据、1位停止、无校验。我们只需要打开发送功能(TE)和整体使能(UE)即可。

如果你想改其他格式,比如9位数据或偶校验,才需要动CR1的其他位或设置CR2

3.2 等待线路空闲,准备发送

虽然还没开始发数据,但建议在首次发送前等待一下状态寄存器:

while (!(USART1->SR & USART_SR_TC)); // 确保上次传输完成(初次运行可省略)

这是个好习惯,尤其在复位后立即发送时,避免状态异常。


第四步:真正发出第一个字节!

终于到了激动人心的时刻。

UART发送的核心逻辑非常清晰:

  1. 查询状态寄存器(SR),看是否允许写入新数据;
  2. TXE(Transmit Data Register Empty)标志置位时,表示TDR空,可以写;
  3. DR寄存器写入一个字节;
  4. 硬件自动将其移位输出;
  5. 移位完成后,TC(Transmission Complete)标志置位。

我们来封装两个函数:

4.1 发送单个字节

void usart_send_byte(USART_TypeDef* usart, uint8_t data) { // 等待TDR为空 while (!(usart->SR & USART_SR_TXE)) { // NOP,忙等待 } // 写入数据寄存器 usart->DR = data; // 可选:等待整个字符帧发送完成 // while (!(usart->SR & USART_SR_TC)); }

📌 注意:DR寄存器是双用途的——写的时候是TDR(发送数据寄存器),读的时候是RDR(接收数据寄存器)。硬件会根据操作方向自动切换。

4.2 发送字符串

有了单字节发送,字符串就容易了:

void usart_send_string(USART_TypeDef* usart, const char* str) { while (*str) { usart_send_byte(usart, *str++); } }

完整主程序演示

现在把这些拼起来,放进main()函数:

int main(void) { // 1. 初始化GPIO与时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN; GPIOA->CRH &= ~(0xF << 4); // PA9 清零 GPIOA->CRH |= (0xB << 4); // MODE9=11 (50MHz), CNF9=10 (AF PP) // 2. 设置波特率 uart_set_baudrate(USART1, 115200); // 3. 使能USART发送 USART1->CR1 |= USART_CR1_TE | USART_CR1_UE; // 4. 循环发送 while (1) { usart_send_string(USART1, "Hello, UART!\r\n"); for (volatile int i = 0; i < 1000000; i++); // 简单延时 } }

编译、下载、打开XCOM或PuTTY,选择对应COM口,设置115200、8N1,你将看到:

Hello, UART! Hello, UART! Hello, UART! ...

每一行都是你亲手“推”出去的。


常见问题排查清单

问题现象可能原因解决方法
屏幕乱码波特率不匹配检查MCU时钟是否真是72MHz,PC端设置是否一致
一个字都收不到引脚配置错误或未开时钟RCC->APB2ENRGPIOA->CRH是否正确
只发一次就停了忘记加延时或中断冲突加软件延时,关闭全局中断测试
字符粘连发送太快没等TXE确保每次发送前都轮询TXE标志
PC收不到电平不匹配使用USB转TTL模块(CH340G/CP2102),不要直连USB

💡 推荐工具:逻辑分析仪抓PA9波形,一眼看出波特率和帧结构是否正确。


更进一步:我们可以做什么?

你现在掌握的是一套“最小可用”的串口输出能力。在此基础上,可以轻松扩展出更多实用功能:

✅ 重定向printf

只需重写fputc函数:

int fputc(int ch, FILE *f) { usart_send_byte(USART1, ch); return ch; }

然后就可以直接用printf("Value: %d\r\n", x);打印变量了。

✅ 构建简易CLI(命令行接口)

接收部分加上中断,就能做简单的命令解析器:

if (received_cmd == "ledon") { LED_ON(); }

适合远程调试设备。

✅ 移植到任意MCU

这套思路适用于几乎所有ARM Cortex-M系列芯片。只要知道:
- 对应的RCC使能位
- GPIO模式配置方式
- UART寄存器映射

就能快速移植。


写在最后:每一个比特都值得被看见

当我们习惯了Serial.println()这种高级封装,很容易忘记底层究竟发生了什么。但正是这些看似繁琐的寄存器配置、波特率计算、标志位查询,构成了嵌入式系统的根基。

掌握UART发送,不是为了替代HAL库,而是为了在库失效时仍有能力自救

下次当你面对一块新板子,没有任何调试信息输出时,请记住:
只要还有一根TX线,还有一个串口助手,你就还有机会让它“说话”。

而这一切,始于你对BRRCR1SRDR这几个寄存器的理解。

现在,轮到你动手试试了——
能不能让你的MCU,在没有库的情况下,说出第一句“Hello”?

欢迎在评论区晒出你的实验结果,我们一起debug。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询