目录
一、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只管"把一个字符送出去"——它不知道这字符从哪来的换输出设备 = 只改
fputc,printf一行不动
这确实就是 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 *指向一个文件(stdout、stderr,或者你用fopen打开的文件)。fputc根据f决定把字符写到哪个文件。
但在单片机裸机上,没有文件系统,printf只有一个输出目标——串口。所以f传进来也不看,直接忽略。这就是为什么 ARM 的 microlib 允许fputc的FILE *f参数形同虚设。
2.2 超时值0xFFFFvs1000的区别
| 函数 | 超时 | 原因 |
|---|---|---|
fputc | 0xFFFF(65535ms) | printf 不能丢字符,等再久也得发完 |
Usart_SendString | 1000(1秒) | 手动调用,1秒发不出说明硬件出问题了 |
fgetc | 1000(1秒) | scanf 等输入,没人敲就超时返回 |
fputc用0xFFFF是一个防御性设计——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会改写你传的变量——这就是为什么fgetc里ch不需要初始化,因为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 里表现为读不到输入)
对比fputc的0xFFFF——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 置 1 | main 检测,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 分发机制 | |
| 完整收发流程 + 双缓冲 |