3、串口通信与调试利器:USART原理与配置、串口中断接收与轮询发送、printf重定向实现、使用串口助手调试门禁日志

做嵌入式开发,说白了就是跟各种外设打交道。而串口,绝对是你用得最多的那个。我常说,串口是嵌入式工程师的「眼睛」和「嘴巴」。没有它,你连芯片在干什么都不知道。

这一章,我们就来彻底搞定串口。从原理到配置,从收发到调试,一步到位。

3.1 USART 原理:为什么叫「通用同步异步收发器」?

USART,全称是 Universal Synchronous Asynchronous Receiver Transmitter。名字很长,但核心就两个词:同步和异步。

  • 异步模式:不需要时钟线。双方约定好波特率,就能通信。这是最常用的模式。
  • 同步模式:需要一根时钟线。速度更快,但多一根线,用得少。

我们门禁系统里,用的就是异步模式。你想想看,门禁控制器和读卡器之间,就两根线(TX、RX),简单可靠。

串口通信的三要素

  • 波特率:每秒传输的位数。常见的有 9600、115200。我习惯用 115200,速度快,误码率也低。
  • 数据位:通常是 8 位。一个字节正好。
  • 停止位:1 位或 2 位。一般用 1 位就够了。

嗯,这里要注意:波特率必须双方一致。差一点,数据就全乱了。我在项目中遇到过,有人把 115200 配成了 1152000,结果串口助手收到的全是乱码。查了半天,才发现是波特率多打了个零。

3.2 USART 配置:从寄存器到 HAL 库

配置串口,其实就三步:

  1. 使能时钟:串口模块和 GPIO 都要开时钟。
  2. 配置 GPIO:TX 设为复用推挽输出,RX 设为浮空输入。
  3. 配置 USART:设置波特率、数据位、停止位、校验位。

用 HAL 库的话,代码很简洁。我个人习惯用 CubeMX 生成初始化代码,省时省力。

// USART2 初始化(HAL 库)
UART_HandleTypeDef huart2;

void MX_USART2_UART_Init(void)
{
    huart2.Instance = USART2;
    huart2.Init.BaudRate = 115200;
    huart2.Init.WordLength = UART_WORDLENGTH_8B;
    huart2.Init.StopBits = UART_STOPBITS_1;
    huart2.Init.Parity = UART_PARITY_NONE;
    huart2.Init.Mode = UART_MODE_TX_RX;
    huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart2.Init.OverSampling = UART_OVERSAMPLING_16;
    HAL_UART_Init(&huart2);
}

你看,就这么几行。但有个坑:GPIO 的复用功能一定要选对。STM32 的引脚功能很多,选错了,串口就废了。我曾经在 F103 上把 PA9 配成了普通 GPIO,结果 TX 死活没波形。用示波器一看,电平纹丝不动。嗯,从那以后,我每次配完 GPIO 都会用逻辑分析仪看一眼。

3.3 串口中断接收:别让 CPU 傻等

轮询接收,就是 CPU 一直死循环等着。这在门禁系统里绝对不行。你想想,CPU 忙着等串口数据,门禁卡来了谁来处理?

所以,必须用中断。数据来了,CPU 才去处理。平时该干嘛干嘛。

// 开启中断接收(一次接收一个字节)
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart2, &rx_byte, 1);

// 中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART2)
    {
        // 处理接收到的字节
        process_rx_byte(rx_byte);
        // 重新开启下一次接收
        HAL_UART_Receive_IT(&huart2, &rx_byte, 1);
    }
}

个人经验:中断回调里不要做耗时操作。比如解析一长串协议,千万别在回调里做。正确的做法是:把数据扔进环形缓冲区,主循环再去处理。我见过有人直接在回调里做字符串比较,结果中断嵌套,系统直接卡死。

3.4 轮询发送:简单可靠

发送数据,我一般用轮询。为什么?因为发送是主动的,CPU 知道什么时候发,等一会儿也无妨。

// 发送一个字符串
HAL_UART_Transmit(&huart2, (uint8_t *)"Hello\r\n", 7, HAL_MAX_DELAY);

最后一个参数是超时时间。我习惯用 HAL_MAX_DELAY,一直等到发完。如果你用 RTOS,可以设个 100ms 超时,防止任务卡死。

注意:轮询发送会阻塞 CPU。如果你在中断里调用发送,一定要小心。我曾经在定时器中断里发串口数据,结果波特率 115200,发一个字节要 87us,中断时间太长,系统时钟都跑偏了。

3.5 printf 重定向:调试利器

用 HAL 库的发送函数,每次都要写 HAL_UART_Transmit,太麻烦了。我习惯用 printf,直接格式化输出,多方便。

怎么做?重定向 fputc 函数就行。

// 重定向 printf 到串口
int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}

然后,你就可以这样用了:

printf("门禁系统启动,当前时间: %d\r\n", system_time);

是不是很爽?但有个坑:MDK 工程里要勾选「Use MicroLIB」,否则链接会报错。我刚开始用的时候,折腾了半天,最后发现是 MicroLIB 没开。

小技巧:如果你用 GCC 工具链,需要额外实现 _write 函数。不同编译器,重定向方式略有不同。但核心思想都一样:把标准输出映射到串口。

3.6 使用串口助手调试门禁日志

硬件调通了,软件写好了,怎么验证?串口助手登场。

我个人习惯用 SSCOM 或者 SecureCRT。设置好波特率、端口号,打开就能看到数据了。

门禁系统的日志,我一般这样设计:

// 日志宏定义
#define LOG_INFO(fmt, ...)  printf("[INFO]  " fmt "\r\n", ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...) printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__)

// 使用示例
LOG_INFO("刷卡事件: 卡号 %08X", card_id);
LOG_ERROR("通信超时: 模块 %d", module_id);

这样,串口助手收到的日志就清晰多了。带时间戳、带级别,一眼就能看出问题。

日志级别 颜色(串口助手) 用途
INFO 白色 正常事件,如刷卡、开门
ERROR 红色 异常事件,如通信失败
DEBUG 绿色 调试信息,如变量值

调试门禁日志的实战技巧

  • 加时间戳:每次打印都带上系统滴答计时,方便定位时序问题。
  • 不要打印太多:DEBUG 级别只在调试时打开。正式版关掉,否则串口会卡死。
  • 用环形缓冲区:如果日志太多,可以先把日志存到缓冲区,主循环再慢慢发。防止中断里打印影响实时性。

我记得有一次,门禁系统偶尔会死机。查了三天,最后发现是串口打印太多,中断频繁触发,把 CPU 时间全吃掉了。关掉 DEBUG 日志,问题立刻消失。嗯,从那以后,我养成了「上线前关调试日志」的习惯。

小结

串口通信,说难不难,说简单也不简单。配置对了,收发正常,printf 一打,整个世界都清晰了。但细节决定成败:波特率对不对?中断回调快不快?日志会不会太多?

这一章的内容,你吃透了,门禁系统的调试就成功了一半。下一章,我们开始搞真正的门禁逻辑——刷卡验证和继电器控制。