F429-HAL-Usart(2026/7/23)

📅 2026/7/23 17:10:41 👁️ 阅读次数 📝 编程学习
F429-HAL-Usart(2026/7/23)

目录

一、printf → fputc 完整流程图

二、两个实际细节

2.1 fputc 参数里的 FILE *f 为什么从来没用到?

2.2 超时值 0xFFFF vs 1000 的区别

三、分层架构总览

四、HAL_UART_Transmit vs HAL_UART_Receive 函数原型对比

对应到代码

一个比喻

超时值的小结

五、接收缓冲区四变量

六、Usart_SendString

七、IDLE 中断要手搓(F4 HAL 没有 IdleCallback)

八、HAL_UART_IRQHandler = 中断分发员

九、完整收发流程(以发 "AT\n" 为例)

十、硬件双缓冲 & 为什么不丢数据(以发 100 字节为例)



​​​​​​​一、printf → fputc 完整流程图

printf("a = %d\r\n", 123); │ │ ① 格式化:把 %d 替换成 '1''2''3',得到纯字符序列 │ 结果: a,空格,=,空格,1,2,3,\r,\n │ │ ② 逐个调用 fputc: │ fputc('a') → fputc(' ') → fputc('=') → fputc(' ') → ... │ ▼ fputc(int ch, FILE *f) │ ③ 决定输出到哪——你现在写的是 UART │ HAL_UART_Transmit(&huart1, &ch, 1, 0xFFFF); │ ▼ PA9 引脚 → CH340 → PC 串口助手

关键认知:

  • printf只管"把数据变成字符"——它不知道串口、LCD、文件是什么

  • fputc只管"把一个字符送出去"——它不知道这字符从哪来的

  • 换输出设备 = 只改fputcprintf一行不动

这确实就是 HAL 库的设计哲学:上层统一接口,下层各管各的硬件

二、两个实际细节

2.1fputc参数里的FILE *f为什么从来没用到?

int fputc(int ch, FILE *f) // ← 这个 f 是干嘛的? { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; // ← 直接忽略了 f }

在 PC 上,FILE *指向一个文件(stdoutstderr,或者你用fopen打开的文件)。fputc根据f决定把字符写到哪个文件。

但在单片机裸机上,没有文件系统,printf只有一个输出目标——串口。所以f传进来也不看,直接忽略。这就是为什么 ARM 的 microlib 允许fputcFILE *f参数形同虚设。

2.2 超时值0xFFFFvs1000的区别

函数超时原因
fputc0xFFFF(65535ms)printf 不能丢字符,等再久也得发完
Usart_SendString1000(1秒)手动调用,1秒发不出说明硬件出问题了
fgetc1000(1秒)scanf 等输入,没人敲就超时返回

fputc0xFFFF是一个防御性设计——printf 格式化一串东西时,中途超时丢一个字符,显示就乱套了。所以宁可死等也不要丢。

三、分层架构总览

┌──────────────────────────────────────────────┐ │ 应用层: printf("ADC = %d\r\n", val) │ ← 只管"说什么" │ scanf("%d", &val) │ ├──────────────────────────────────────────────┤ │ 抽象层: fputc(ch, f) fgetc(f) │ ← 只管"送到哪" │ (C 标准库定义的接口,你负责重写) │ ├──────────────────────────────────────────────┤ │ 驱动层: HAL_UART_Transmit / HAL_UART_Receive│ ← 操作硬件寄存器 │ LCD_ShowChar / USB_CDC_Transmit │ 换设备就换这层 │ (换设备只改这里,上面两层不变) │ ├──────────────────────────────────────────────┤ │ 硬件层: PA9(TX) / PA10(RX) / CH340 │ ← 物理引脚和芯片 └──────────────────────────────────────────────┘

这就是分层和接口抽象——每一层只关心自己的事,上下层通过固定接口连接。HAL 库对每个外设(I2C、SPI 等)都是这个套路。

四、HAL_UART_Transmit vs HAL_UART_Receive 函数原型对比

// 发送:把你的数据推出去 HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, // 数据从哪读 uint16_t Size, uint32_t Timeout); ​ // 接收:把外面的数据收进来 HAL_StatusTypeDef HAL_UART_Receive( UART_HandleTypeDef *huart, uint8_t *pData, // 数据往哪存 uint16_t Size, uint32_t Timeout);

函数签名几乎一样,区别只在pData的方向:

Transmit(发送)Receive(接收)
方向内存 → 串口 TX → 外面外面 → 串口 RX → 内存
pData作用数据来源:从这个地址读数据往外发数据去处:收到的数据存到这个地址
pData要指向什么已经有内容的变量/数组一块空闲的内存,等 HAL 往里填
调用完以后你的数据发出去了,pData 内容不变pData 指向的内存被改写了(填入了收到的字节)

对应到代码

// ── 发送 ── HAL_UART_Transmit(&huart1, (uint8_t *)(str + k), 1, 1000); // └───── 来源 ─────┘ // 从 str[k] 读 1 字节,推到 TX 引脚 ​ // ── 接收 ── HAL_UART_Receive(&huart1, (uint8_t *)&ch, 1, 1000); // └── 去处 ──┘ // 从 RX 引脚收 1 字节,存进 ch 变量里

一个比喻

Transmit — 你递东西出去: 你 → 把 str[k] 的内容抄一份 → 塞进 TX 寄存器 → 串口发出 ​ Receive — 你接东西进来: 外面发来 → RX 寄存器收到 → 抄一份 → 写进 ch 变量(覆盖 ch 原来的值)

Transmit不会改你传的数据,Receive会改写你传的变量——这就是为什么fgetcch不需要初始化,因为HAL_UART_Receive会往里填:

int fgetc(FILE *f) { int ch; // ← 没初始化,值是随机的(无所谓) HAL_UART_Receive(&huart1, (uint8_t *)&ch, 1, 1000); // 接收完成后 ch = 刚收到的那个字节 return ch; // ← 现在 ch 有值了 }

超时值的小结

Usart_SendString发数据 和fgetc收数据 都用1000(1秒):

  • 发送 1 秒超时:一个字节都发不出去 → 硬件大概率挂了,别再死等

  • 接收 1 秒超时:1 秒内没人发数据来 → 不等了,返回(scanf 里表现为读不到输入)

对比fputc0xFFFF——printf 不能中途丢字符,所以发送超时设得极长。而手动调用的函数更务实:1 秒没反应就该报错了。

五、接收缓冲区四变量

uint8_t usart1_rx_buf[50]; // 接收缓冲区(快递柜),50 字节上限 uint8_t usart1_rx_len; // 当前帧已收到的字节数(也是下次存入的下标) volatile uint8_t usart1_rx_flag; // 帧完成标志,1 = 有新帧待处理 static uint8_t rx_byte; // 暂存器,HAL_Receive_IT 往里填,回调再抄进 buf
变量作用谁写谁读
usart1_rx_buf[50]快递柜中断回调main
usart1_rx_len计数器 + 存放下标中断回调main
usart1_rx_flag帧完成门铃ISR 置 1main 检测,main 清 0
rx_byte(static)单字节暂存筐HAL 内部回调抄走

关键细节:

  • volatile不能省:变量在中断里改、main 里读,不加volatile编译器可能优化到寄存器,main 永远看不到变化

  • len用完必须归零:下次从buf[0]覆盖写。旧数据残留不影响,因为len控制只读前 N 个

  • flag必须手动清零:ISR 只负责置 1,main 处理完自己写 0

六、Usart_SendString

void Usart_SendString(uint8_t *str) { unsigned int k = 0; do { HAL_UART_Transmit(&huart1, (uint8_t *)(str + k), 1, 1000); k++; } while (*(str + k) != '\0'); // 遇 \0 停 }
  • 逐字节阻塞发送,不限长(遇\0才停)

  • 调用格式:Usart_SendString((uint8_t *)"Hello\n");

  • 跟 printf 的区别:不能格式化,纯发固定文本;printf 走 fputc,能拼变量值

  • 内容固定用 SendString,内容有变量用 printf

七、IDLE 中断要手搓(F4 HAL 没有 IdleCallback)

ST 的 F4 HAL 库所有版本都没有 IDLE 回调——不是版本不够,是 F4 全系都没做。新系列(G0/G4/H5)才有HAL_UARTEx_ReceiveToIdle_IT()

所以必须在USART1_IRQHandler里手动补三行:

void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); // HAL 自动处理 RXNE /* ↓ 下面三行是手动补的,HAL 不会帮你做 ↓ */ if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // ① 清硬件 IDLE 标志 usart1_rx_flag = 1; // ② 置软件标志,通知 main } }

注意分清两个"标志":

  • 硬件 IDLE 标志(USART 状态寄存器里的位):硬件自动置 1,必须用__HAL_UART_CLEAR_IDLEFLAG软件清除,否则下次不进 IDLE 中断

  • 软件usart1_rx_flag(你自己定义的内存变量):ISR 写 1,main 处理完写 0

八、HAL_UART_IRQHandler = 中断分发员

入口——每个外设的中断函数名是定死的。

启动文件(startup_stm32f429xx.s)里写好了向量表:

USART1_IRQHandler ← USART1 中断来时 CPU 跳这里 USART2_IRQHandler ← USART2 中断 TIM6_IRQHandler ← TIM6 中断 EXTI0_IRQHandler ← 外部中断 0 ...

你只能按这些名字写函数,不能自己起。如果用 USART2,就是同样套路:

void USART2_IRQHandler(void) { HAL_UART_IRQHandler(&huart2); // 传 huart2 // 同样的手搓 IDLE... }

HAL_UART_IRQHandler 内部干了什么:

没有 HAL 的年代,ISR 里要自己写一堆 if 查标志位(F1 标准库写法):

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) // 是 RXNE 吗? { buf[i++] = USART_ReceiveData(USART1); // 手动读 USART_ClearITPendingBit(USART1, USART_IT_RXNE); // 手动清标志 } if (USART_GetITStatus(USART1, USART_IT_TXE) != RESET) // 是 TXE 吗? { // 手动处理发送... } // 每个 USART 都得写这么一堆 }

HAL 把这套"查标志 → 处理 → 清标志"全部封进了HAL_UART_IRQHandler

ISR → HAL_UART_IRQHandler → 读 SR,看哪个标志亮了 ├─ RXNE → 读 DR → 填 pData 地址 → 收满 N 个调 RxCpltCallback ├─ TXE → 装下一字节 → 发完调 TxCpltCallback └─ IDLE → ST 没处理(你自己补)

同一个回调,区分不同 USART——靠huart->Instance

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) // USART1 收到的 { usart1_rx_buf[usart1_rx_len++] = rx_byte; } else if (huart->Instance == USART2) // USART2 收到的 { usart2_rx_buf[usart2_rx_len++] = rx_byte; } // USART1/2/3/6 共用一个回调,靠 Instance 分家 }

HAL 的设计就是:ISR 函数名按外设固定 → 调用同一个 HAL_UART_IRQHandler → 内部判断标志位 → 回调同一个函数 → 靠 huart->Instance 分家。你只需要重写回调,剩下的 HAL 全包了。

九、完整收发流程(以发 "AT\n" 为例)

PC 发 "AT\n"(3 字节) │ ├─ 字节 'A':移位收 87us → 搬进 DR → RXNE=1 → ISR → HAL 读 DR → rx_byte='A' │ └─ 回调:buf[0]='A', len=1, 重启接收 │ ├─ 字节 'T':同 ↑,buf[1]='T', len=2 │ ├─ 字节 '\n':同 ↑,buf[2]='\n', len=3 │ └─ 总线空闲 >1 字节时间 → IDLE 标志置 1 → 进 ISR └─ HAL_UART_IRQHandler 这次什么都没做(RXNE=0) └─ 手搓三行命中:清 IDLE 标志 + flag=1 main 轮询: if (flag == 1) { 处理 buf[0..2] = "AT\n" flag = 0, len = 0 // 归位,准备收下一帧 }

为什么 IDLE 前三次都跳过?如果每收到一个字节就 flag=1,那 "AT\n" 会被拆成三个碎片,没法当完整一帧用。IDLE 的"空闲判断"保证了 flag 只在整帧收完后置一次。

十、硬件双缓冲 & 为什么不丢数据(以发 100 字节为例)

PC 一口气发 100 个字节,每个字节约 87us(115200 波特率),总时间约 8.7ms。CPU 这边发生了什么:

第 1 字节进入移位寄存器(87us) │ 移位寄存器:从起始位开始,逐 bit 滑入 8 位数据 │ 87us 后凑满 → 一把搬到 DR(数据寄存器)→ 硬件置 RXNE=1 │ 同时,移位寄存器空出来,立刻开始收第 2 字节 ← 关键! ▼ RXNE=1 → CPU 进 ISR → HAL 发现 RXNE → 读 DR,拿到第 1 字节 → 写进 rx_byte → 回调:buf[0] = rx_byte,len=1 → 重启 HAL_UART_Receive_IT → ISR 退出(全程约 3us) │ │ 此时移位寄存器正在收第 2 字节(还没收完,87us 才满) │ CPU 干完活了,余量 84us 发呆 ▼ 第 2 字节凑满 → 搬到 DR → RXNE=1 → CPU 再进 ISR...

逐字节节奏:

字节1: [移位收87us]→[搬DR]→[RXNE]→[ISR读走 3us]→ DR空 ↕ CPU 等 84us 字节2: [移位收87us]→[搬DR]→[RXNE]→[ISR读走 3us]→ DR空 ↕ CPU 等 84us ... 字节100: ...读走 CPU 每 87us 只忙 3us,剩下 84us 闲着,收一辈子也丢不了

什么时候才丢?只有 ISR 卡住超过 87us——比如在 ISR 里写了HAL_Delay(100),第 2 字节搬进 DR 时第 1 字节还没被读走,硬件报 ORE(溢出错误)。正常代码不可能犯这种错误。

核心原因:移位寄存器和 DR 是两个独立的硬件,一个负责慢慢收、一个负责等你来取,同时工作互不耽误。

测试代码主函数:

#include "stm32f4xx_hal.h" #include <stdio.h> #include "./LED/LED.h" #include "./Key/Key.h" #include "./Exti/Exti.h" #include "./Usart/Usart.h" #include "./TIM6/TIM6.h" #include "./SysTick/SysTick.h" int main(void) { HAL_Init(); SysTick_Init(); /* 覆盖为 10us 中断,Delay_us/Delay_ms 可用 */ LED_Init(); Key_Init(); Exti_Key_Init(); Usart1_Init(115200); TIM6_Init(); Usart_SendString((uint8_t *)"这是一个基于串口的回显试验"); while (1) { /* ── 收:有人发数据来就处理 ── */ if (usart1_rx_flag == 1) { printf("收: "); HAL_UART_Transmit(&huart1, usart1_rx_buf, usart1_rx_len, 1000); printf("\r\n"); usart1_rx_flag = 0; usart1_rx_len = 0; } } }

今天从函数原型到硬件底层全串通了,收获不小 👊

上午下午
Transmit vs Receive 原型缓冲区四变量
fputc/fgetc 重定向机制Usart_SendString
分层架构IDLE 手搓原因
HAL_UART_IRQHandler 分发机制
完整收发流程 + 双缓冲