)
目录一、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 替换成 123得到纯字符序列 │ 结果: 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 *指向一个文件stdout、stderr或者你用fopen打开的文件。fputc根据f决定把字符写到哪个文件。但在单片机裸机上没有文件系统printf只有一个输出目标——串口。所以f传进来也不看直接忽略。这就是为什么 ARM 的 microlib 允许fputc的FILE *f参数形同虚设。2.2 超时值0xFFFFvs1000的区别函数超时原因fputc0xFFFF65535msprintf 不能丢字符等再久也得发完Usart_SendString10001秒手动调用1秒发不出说明硬件出问题了fgetc10001秒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收数据 都用10001秒发送 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]快递柜中断回调mainusart1_rx_len计数器 存放下标中断回调mainusart1_rx_flag帧完成门铃ISR 置 1main 检测main 清 0rx_byte(static)单字节暂存筐HAL 内部回调抄走关键细节volatile不能省变量在中断里改、main 里读不加volatile编译器可能优化到寄存器main 永远看不到变化len用完必须归零下次从buf[0]覆盖写。旧数据残留不影响因为len控制只读前 N 个flag必须手动清零ISR 只负责置 1main 处理完自己写 0六、Usart_SendStringvoid 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 没有 IdleCallbackST 的 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 写 1main 处理完写 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_IRQHandlerISR → HAL_UART_IRQHandler → 读 SR看哪个标志亮了 ├─ RXNE → 读 DR → 填 pData 地址 → 收满 N 个调 RxCpltCallback ├─ TXE → 装下一字节 → 发完调 TxCpltCallback └─ IDLE → ST 没处理你自己补同一个回调区分不同 USART——靠huart-Instancevoid 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\n3 字节 │ ├─ 字节 A移位收 87us → 搬进 DR → RXNE1 → ISR → HAL 读 DR → rx_byteA │ └─ 回调buf[0]A, len1, 重启接收 │ ├─ 字节 T同 ↑buf[1]T, len2 │ ├─ 字节 \n同 ↑buf[2]\n, len3 │ └─ 总线空闲 1 字节时间 → IDLE 标志置 1 → 进 ISR └─ HAL_UART_IRQHandler 这次什么都没做RXNE0 └─ 手搓三行命中清 IDLE 标志 flag1 main 轮询: if (flag 1) { 处理 buf[0..2] AT\n flag 0, len 0 // 归位准备收下一帧 }为什么 IDLE 前三次都跳过如果每收到一个字节就 flag1那 AT\n 会被拆成三个碎片没法当完整一帧用。IDLE 的空闲判断保证了 flag 只在整帧收完后置一次。十、硬件双缓冲 为什么不丢数据以发 100 字节为例PC 一口气发 100 个字节每个字节约 87us115200 波特率总时间约 8.7ms。CPU 这边发生了什么第 1 字节进入移位寄存器87us │ 移位寄存器从起始位开始逐 bit 滑入 8 位数据 │ 87us 后凑满 → 一把搬到 DR数据寄存器→ 硬件置 RXNE1 │ 同时移位寄存器空出来立刻开始收第 2 字节 ← 关键 ▼ RXNE1 → CPU 进 ISR → HAL 发现 RXNE → 读 DR拿到第 1 字节 → 写进 rx_byte → 回调buf[0] rx_bytelen1 → 重启 HAL_UART_Receive_IT → ISR 退出全程约 3us │ │ 此时移位寄存器正在收第 2 字节还没收完87us 才满 │ CPU 干完活了余量 84us 发呆 ▼ 第 2 字节凑满 → 搬到 DR → RXNE1 → 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 分发机制完整收发流程 双缓冲