第4章:UART驱动开发:UART协议基础、串口控制器寄存器配置、Linux串口驱动框架与实现

4.1 先聊聊UART协议那些事儿

UART,说白了就是串口通信。我刚开始做嵌入式时,觉得它太简单了——不就是两根线收发数据嘛。但后来踩过坑才明白,越是基础的协议,越容易在细节上翻车。

UART的全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。它最大的特点就是「异步」——收发双方不需要共享时钟信号。那怎么保证节奏一致呢?靠的是波特率(Baud Rate)。

举个例子,你设置115200波特率,意味着每秒传输115200个比特。收发双方必须约定好同一个波特率,否则数据就会错乱。我在项目中遇到过,有人把波特率设成了115200和1152000,结果收出来的数据全是乱码。嗯,这种低级错误其实挺常见的。

UART的数据帧格式是这样的:

  • 起始位:1位,逻辑0,表示数据开始
  • 数据位:5~8位,通常是8位
  • 校验位:可选,奇校验或偶校验
  • 停止位:1位或2位,逻辑1,表示数据结束

你想想看,为什么要有起始位和停止位?因为异步通信没有时钟线,接收端需要靠这些标志位来「对齐」数据。我曾经调试过一个设备,停止位设成了2位,但对方只支持1位,结果通信时断时续。排查了半天才发现是这个小问题。

关键点:UART通信必须保证波特率、数据位、校验位、停止位完全一致。任何一个参数不匹配,数据就废了。

4.2 串口控制器寄存器配置实战

好了,协议讲完了,咱们看看硬件怎么配。以我常用的AMBA APB总线上的UART控制器为例,它的寄存器布局大致如下:

偏移地址 寄存器名称 功能描述
0x00 UART_RBR 接收缓冲寄存器(读)
0x00 UART_THR 发送保持寄存器(写)
0x04 UART_IER 中断使能寄存器
0x08 UART_IIR 中断标识寄存器
0x0C UART_LCR 线路控制寄存器
0x10 UART_MCR 调制解调器控制寄存器
0x14 UART_LSR 线路状态寄存器
0x18 UART_MSR 调制解调器状态寄存器
0x1C UART_DLL 除数锁存器低字节
0x20 UART_DLH 除数锁存器高字节

配置UART的步骤,我个人习惯按这个顺序来:

  1. 设置波特率:先往LCR的DLAB位写1,使能除数锁存器访问。然后计算除数 = 时钟频率 / (16 × 目标波特率),分别写入DLL和DLH。
  2. 配置数据格式:往LCR写入数据位、停止位、校验位配置。比如8位数据、1位停止位、无校验,就写0x03。
  3. 使能FIFO:往FCR写入0x01,使能收发FIFO。我建议把触发阈值设为8字节,这样中断频率比较合理。
  4. 配置中断:往IER写入你想使能的中断类型,比如接收数据可用中断、发送保持寄存器空中断等。
  5. 最后使能UART:有些控制器需要往MCR或某个控制位写1来启动。

小技巧:配置波特率时,除数的计算一定要用整数除法。如果除不尽,实际波特率会有误差。我一般要求误差不超过2%,否则长时间通信会丢数据。

来看一段实际的寄存器配置代码:

// 假设UART基地址为0x10000000
#define UART_BASE       0x10000000
#define UART_LCR        (UART_BASE + 0x0C)
#define UART_DLL        (UART_BASE + 0x1C)
#define UART_DLH        (UART_BASE + 0x20)
#define UART_FCR        (UART_BASE + 0x08)
#define UART_IER        (UART_BASE + 0x04)

void uart_init(uint32_t baudrate) {
    uint32_t divisor;
    
    // 1. 使能DLAB,访问除数锁存器
    writel(0x80, UART_LCR);
    
    // 2. 计算并设置波特率除数
    divisor = 50000000 / (16 * baudrate);  // 假设时钟50MHz
    writel(divisor & 0xFF, UART_DLL);
    writel((divisor >> 8) & 0xFF, UART_DLH);
    
    // 3. 配置8N1格式,关闭DLAB
    writel(0x03, UART_LCR);
    
    // 4. 使能FIFO,触发阈值设为8字节
    writel(0x41, UART_FCR);
    
    // 5. 使能接收中断
    writel(0x01, UART_IER);
}

这段代码看起来简单,但有个坑——writel函数的实现必须保证是内存映射IO,不能有cache干扰。我曾经在某个SoC上忘了配置页表属性,结果写寄存器没生效,查了一整天。

4.3 Linux串口驱动框架解析

Linux内核的串口驱动框架,说白了就是一套分层设计。我刚开始看源码时觉得好复杂,但摸清套路后会发现,它其实很优雅。

核心结构体是uart_driveruart_port。前者代表一个串口驱动,后者代表一个具体的串口端口。驱动开发者的主要工作就是实现uart_ops回调函数集:

struct uart_ops {
    unsigned int (*tx_empty)(struct uart_port *port);
    void (*set_mctrl)(struct uart_port *port, unsigned int mctrl);
    unsigned int (*get_mctrl)(struct uart_port *port);
    void (*stop_tx)(struct uart_port *port);
    void (*start_tx)(struct uart_port *port);
    void (*stop_rx)(struct uart_port *port);
    void (*enable_ms)(struct uart_port *port);
    void (*break_ctl)(struct uart_port *port, int ctl);
    int (*startup)(struct uart_port *port);
    void (*shutdown)(struct uart_port *port);
    void (*set_termios)(struct uart_port *port, struct ktermios *new, struct ktermios *old);
    void (*pm)(struct uart_port *port, unsigned int state, unsigned int oldstate);
    int (*set_ldisc)(struct uart_port *port, struct ktermios *termios);
    void (*poll_get_char)(struct uart_port *port);
    void (*poll_put_char)(struct uart_port *port);
};

这里面最核心的是startupshutdownset_termios。startup里做硬件初始化,shutdown里做清理,set_termios里处理波特率、数据格式等配置变更。

我举个例子,set_termios的实现大概长这样:

static void my_uart_set_termios(struct uart_port *port,
                                struct ktermios *new,
                                struct ktermios *old)
{
    unsigned int baud, cflag;
    unsigned int divisor;
    
    cflag = new->c_cflag;
    
    // 计算波特率
    baud = uart_get_baud_rate(port, new, old, 0, 115200*16);
    divisor = port->uartclk / (16 * baud);
    
    // 更新硬件寄存器
    writel(divisor & 0xFF, port->membase + UART_DLL);
    writel((divisor >> 8) & 0xFF, port->membase + UART_DLH);
    
    // 更新线路控制寄存器
    // ... 根据cflag配置数据位、停止位、校验位
    
    // 通知内核波特率已更新
    uart_update_timeout(port, cflag, baud);
}

这里有个细节——uart_get_baud_rate是内核提供的辅助函数,它会根据用户空间的设置自动计算最接近的波特率。但如果你硬件支持的波特率比较特殊,记得在uart_portops里实现set_termios时做一下范围检查。

注意:中断处理函数里不要做耗时操作。我曾经在中断里直接调用writel配置寄存器,结果导致中断延迟过高,系统响应变慢。正确的做法是:中断里只做数据搬运,配置操作放到工作队列或tasklet里。

4.4 驱动实现中的几个关键点

写一个完整的UART驱动,我觉得有几点特别值得注意:

  • 中断处理要高效:接收中断来临时,尽快把FIFO里的数据读到内存缓冲区。发送中断则相反,把缓冲区数据写入THR。我习惯用uart_insert_charuart_write_wakeup这两个内核API来和上层交互。
  • 流控不能忘:硬件流控(RTS/CTS)在高速通信时几乎是必须的。如果不做流控,接收端来不及处理数据,就会丢包。我建议在set_termios里根据CRTSCTS标志位来配置。
  • 电源管理要到位:现在的SoC都讲究低功耗。UART空闲时应该能进入休眠状态,有数据时再唤醒。实现pm回调函数时,记得保存和恢复寄存器状态。

嗯,说到电源管理,我想起一个案例。有次客户反映设备在低功耗模式下串口无法唤醒。排查后发现是中断唤醒配置没做——需要在设备树里加上wakeup-source属性,并且在驱动初始化时调用device_init_wakeup使能唤醒功能。

4.5 调试与验证

驱动写完了,怎么验证对不对?我一般用这几个方法:

  1. 回环测试:把TXD和RXD短接,发什么收什么。如果收发一致,说明基本通路没问题。
  2. 波特率精度测试:用示波器看TXD引脚的波形,测量实际波特率。误差超过2%就要调整时钟源或除数计算方式。
  3. 压力测试:用dd if=/dev/ttyS0 of=/dev/null bs=1024 count=10000连续收发大量数据,观察有没有丢数据或报错。

我曾经在调试一个高速UART时,发现波特率设到3Mbps后数据就频繁出错。用示波器一看,波形上升沿和下降沿都变圆了——原来是PCB走线太长,信号质量不行。后来在驱动里加了软件流控才勉强能用。所以说,硬件问题有时候也得靠软件来兜底。

调试小工具:内核的CONFIG_SERIAL_CORE_CONSOLE可以把串口作为控制台,这样printk信息就能从串口输出。我习惯在驱动初始化时加一些printk打印寄存器值,方便确认配置是否正确。

好了,UART驱动开发的核心内容就这些。从协议基础到寄存器配置,再到Linux驱动框架,每一步都有不少细节。但只要你把基础打牢了,遇到问题能沉下心来看波形、读手册,就没有搞不定的串口。下一章我们聊聊SPI驱动,那个协议比UART稍微复杂一点,但套路是相通的。