3、UART串口通信驱动开发

UART串口,搞嵌入式的人天天打交道。说它是物联网设备最基础的通信方式,一点不过分。我最早接触鸿蒙南向开发时,第一个调通的外设就是UART。为什么?因为串口控制台是调试的命脉,你连log都打不出来,后面还怎么玩?

这一章,咱们就把UART从协议到驱动,从配置到实战,彻底捋一遍。

3.1 UART协议基础

UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。说白了,就是一根线发数据,一根线收数据,再加一根地线,三根线就能通信。

为什么叫异步?因为没有独立的时钟线。收发双方靠约定的波特率来同步。你想想看,两个人约好每秒说多少个字,不用看表也知道对方什么时候开口。

参数 说明 常见值
波特率 每秒传输的符号数 9600、115200
数据位 一帧中有效数据的位数 8位(最常用)
停止位 帧结束标志位 1位、2位
校验位 可选奇偶校验 无、奇校验、偶校验

一帧UART数据长这样:起始位(低电平)+ 数据位(LSB或MSB优先)+ 可选的校验位 + 停止位(高电平)。空闲时,TX线保持高电平。

我的习惯:调试阶段一律用115200-8-N-1。115200波特率够快,8位数据位兼容性好,无校验省事,1位停止位够用。等产品定型了再根据功耗和距离调整。

3.2 鸿蒙UART驱动框架

鸿蒙的UART驱动框架,走的是HDF(Hardware Driver Foundation)路线。你不需要直接操作寄存器,框架帮你把硬件抽象好了。

整体架构分三层:

  • 应用层:通过POSIX接口或HDF IO Service调用
  • 驱动框架层:HDF核心、UART控制器管理、回调注册
  • 硬件适配层:芯片厂商实现的Platform驱动,操作具体寄存器

我刚开始看HDF框架时,觉得层数太多。后来发现,这种分层在芯片换型时特别香——你只需要重写最底层的适配代码,上层完全不用动。

鸿蒙UART驱动框架分层结构 应用层 POSIX open/read/write / HDF IO Service 驱动框架层(HDF Core) UART控制器管理 · 设备节点注册 · 回调分发 HdfUartDevice · UartHost · Dispatch 硬件适配层(Platform Driver) UartDevice · 寄存器操作 · 中断处理 · DMA配置 Hisilicon · Rockchip · Allwinner 等芯片适配 开发者通常只需要关注驱动框架层的接口调用,硬件适配层由芯片厂商提供

3.3 串口收发数据实现

在鸿蒙上操作UART,最直接的方式是用HDF提供的UART接口。我习惯先调通一个简单的回环测试——发什么收什么,确认硬件链路没问题。

核心步骤:

  1. 通过HDF获取UART设备句柄
  2. 配置波特率、数据格式
  3. 注册接收回调或轮询读取
  4. 调用发送接口写数据

下面是一个简化的收发示例:

#include "hdf_io_service.h"
#include "uart_if.h"

// 获取UART设备
struct UartHost *uartHost = UartOpen(0);
if (uartHost == NULL) {
    printf("UART open failed\n");
    return -1;
}

// 配置波特率115200,8N1
struct UartAttribute attr;
attr.baudRate = 115200;
attr.dataBits = UART_ATTR_DATABIT_8;
attr.parity = UART_ATTR_PARITY_NONE;
attr.stopBits = UART_ATTR_STOPBIT_1;
UartSetAttribute(uartHost, &attr);

// 发送数据
uint8_t txBuf[] = "Hello UART\n";
int32_t ret = UartWrite(uartHost, txBuf, sizeof(txBuf));

// 接收数据(轮询方式)
uint8_t rxBuf[64];
int32_t len = UartRead(uartHost, rxBuf, sizeof(rxBuf));
我曾经踩过的坑:UartOpen返回的句柄不是POSIX的fd,不能直接用read/write。必须用HDF的UartRead/UartWrite。第一次用的时候我拿read去读,结果一直返回-1,查了半天才发现接口用错了。

3.4 波特率与数据格式配置

波特率配置是UART驱动里最容易出问题的地方。为什么?因为实际波特率和理论值往往有偏差,取决于时钟源和分频系数。

鸿蒙的UART框架里,UartSetAttribute会帮你做分频计算。但如果你用的芯片时钟比较特殊,建议手动算一下实际波特率误差。

目标波特率 时钟频率(MHz) 分频系数 实际波特率 误差
115200 16 139 115107 0.08%
9600 16 1667 9598 0.02%
115200 24 208 115384 0.16%

误差在2%以内通常没问题。超过2%,连续收几个字节就可能错位。我遇到过一块开发板,串口芯片用的外部晶振不准,115200下误差到了3.5%,收长数据包必丢字节。后来降到57600才稳定。

数据格式配置要点:

  • 数据位:8位最通用,7位用于某些老协议
  • 校验位:无校验最省事,奇偶校验能检测单bit错误
  • 停止位:1位足够,2位用于慢速设备或长距离
我建议:如果通信双方都是你控制的,统一用115200-8-N-1。如果对接第三方设备,先看对方手册,别想当然。我曾经对接一个工业传感器,手册写9600-7-E-1,我按8-N-1配,log全是乱码。

3.5 实战:串口控制台交互

理论说完了,咱们动手做一个串口控制台。功能很简单:通过串口输入命令,设备解析并返回结果。这是物联网设备调试的标配功能。

设计思路:

  1. UART中断接收,逐字节存入环形缓冲区
  2. 检测到换行符(\n或\r\n)时,提取一行命令
  3. 解析命令字符串,匹配预定义命令表
  4. 执行对应函数,通过UART发送返回结果

命令表结构:

typedef struct {
    const char *cmd;
    void (*handler)(char *args);
} CmdEntry;

static CmdEntry g_cmdTable[] = {
    {"help",    CmdHelp},
    {"version", CmdVersion},
    {"reset",   CmdReset},
    {"gpio",    CmdGpio},
    {"adc",     CmdAdc},
    {NULL,      NULL}
};

主循环里这样处理:

while (1) {
    // 从环形缓冲区取一行
    char *line = GetLineFromRingBuffer();
    if (line == NULL) {
        continue;
    }
    // 去掉尾部换行符
    TrimNewline(line);
    // 查找命令表
    for (int i = 0; g_cmdTable[i].cmd != NULL; i++) {
        if (strncmp(line, g_cmdTable[i].cmd, strlen(g_cmdTable[i].cmd)) == 0) {
            char *args = line + strlen(g_cmdTable[i].cmd);
            while (*args == ' ') args++;
            g_cmdTable[i].handler(args);
            break;
        }
    }
    // 没匹配到,打印错误
    UartWrite(uartHost, "Unknown command\n", 16);
}

实际效果:串口输入 help,返回所有支持的命令列表;输入 gpio on,点亮板载LED;输入 adc,读取并返回当前ADC值。整个交互过程不到50行核心代码。

这个控制台框架我用了好几年,从STM32移植到鸿蒙,基本结构没变过。你想想看,有了这个基础,后面加I2C、SPI设备的调试命令,就是加一行表项的事。

嗯,UART串口这块就聊到这儿。记住一句话:串口是嵌入式工程师的耳朵和嘴巴,调通它,你才能听见设备在说什么。