第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的步骤,我个人习惯按这个顺序来:
- 设置波特率:先往LCR的DLAB位写1,使能除数锁存器访问。然后计算除数 = 时钟频率 / (16 × 目标波特率),分别写入DLL和DLH。
- 配置数据格式:往LCR写入数据位、停止位、校验位配置。比如8位数据、1位停止位、无校验,就写0x03。
- 使能FIFO:往FCR写入0x01,使能收发FIFO。我建议把触发阈值设为8字节,这样中断频率比较合理。
- 配置中断:往IER写入你想使能的中断类型,比如接收数据可用中断、发送保持寄存器空中断等。
- 最后使能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_driver和uart_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);
};
这里面最核心的是startup、shutdown和set_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_port的ops里实现set_termios时做一下范围检查。
注意:中断处理函数里不要做耗时操作。我曾经在中断里直接调用writel配置寄存器,结果导致中断延迟过高,系统响应变慢。正确的做法是:中断里只做数据搬运,配置操作放到工作队列或tasklet里。
4.4 驱动实现中的几个关键点
写一个完整的UART驱动,我觉得有几点特别值得注意:
- 中断处理要高效:接收中断来临时,尽快把FIFO里的数据读到内存缓冲区。发送中断则相反,把缓冲区数据写入THR。我习惯用
uart_insert_char和uart_write_wakeup这两个内核API来和上层交互。 - 流控不能忘:硬件流控(RTS/CTS)在高速通信时几乎是必须的。如果不做流控,接收端来不及处理数据,就会丢包。我建议在
set_termios里根据CRTSCTS标志位来配置。 - 电源管理要到位:现在的SoC都讲究低功耗。UART空闲时应该能进入休眠状态,有数据时再唤醒。实现
pm回调函数时,记得保存和恢复寄存器状态。
嗯,说到电源管理,我想起一个案例。有次客户反映设备在低功耗模式下串口无法唤醒。排查后发现是中断唤醒配置没做——需要在设备树里加上wakeup-source属性,并且在驱动初始化时调用device_init_wakeup使能唤醒功能。
4.5 调试与验证
驱动写完了,怎么验证对不对?我一般用这几个方法:
- 回环测试:把TXD和RXD短接,发什么收什么。如果收发一致,说明基本通路没问题。
- 波特率精度测试:用示波器看TXD引脚的波形,测量实际波特率。误差超过2%就要调整时钟源或除数计算方式。
- 压力测试:用
dd if=/dev/ttyS0 of=/dev/null bs=1024 count=10000连续收发大量数据,观察有没有丢数据或报错。
我曾经在调试一个高速UART时,发现波特率设到3Mbps后数据就频繁出错。用示波器一看,波形上升沿和下降沿都变圆了——原来是PCB走线太长,信号质量不行。后来在驱动里加了软件流控才勉强能用。所以说,硬件问题有时候也得靠软件来兜底。
调试小工具:内核的CONFIG_SERIAL_CORE_CONSOLE可以把串口作为控制台,这样printk信息就能从串口输出。我习惯在驱动初始化时加一些printk打印寄存器值,方便确认配置是否正确。
好了,UART驱动开发的核心内容就这些。从协议基础到寄存器配置,再到Linux驱动框架,每一步都有不少细节。但只要你把基础打牢了,遇到问题能沉下心来看波形、读手册,就没有搞不定的串口。下一章我们聊聊SPI驱动,那个协议比UART稍微复杂一点,但套路是相通的。