4、UART驱动开发:TTY子系统介绍、串口设备树配置、串口收发数据实战
好,咱们今天聊串口。UART,嵌入式开发里最基础、也最常用的通信接口。你想想看,从调试打印到跟传感器、GPS模块、蓝牙模块通信,哪样离得开它?我做了这么多年HMI开发,串口这块踩过的坑,说多了都是泪。今天咱们就把串口驱动开发的几个核心点掰开揉碎讲清楚。
4.1 TTY子系统:串口背后的“大管家”
很多初学者会问:我明明操作的是串口硬件,为什么Linux里要搞个TTY子系统?说白了,TTY就是Linux对终端设备的一种抽象。它把键盘、显示器、串口这些乱七八糟的输入输出设备,统一包装成一个“终端”接口。
我个人习惯把TTY子系统理解成一个“中间人”。上层应用(比如你的Qt程序、shell)通过标准的文件操作——open、read、write——来跟TTY打交道。TTY子系统负责处理行规约(line discipline)、信号处理、回显这些脏活累活。下层才是真正的串口驱动,负责操作UART硬件寄存器。
嗯,这里要注意:TTY子系统分三层:
- TTY核心层:提供统一的设备号管理、文件操作接口。说白了就是给应用层看的“门面”。
- 线路规程(Line Discipline):这是TTY的灵魂。它负责数据缓冲、编辑(比如退格、删除)、特殊字符处理(Ctrl+C中断)。默认的N_TTY规程处理标准终端行为。
- 串口驱动层:直接跟硬件寄存器打交道。我们写串口驱动,主要就是实现这一层。
关键点:应用层通过/dev/ttyS0、/dev/ttyAMA0这些设备节点访问串口。这些节点背后,就是TTY子系统在调度。
我在项目中遇到过一个问题:某款HMI设备,串口打印偶尔会丢字符。查了半天,发现是线路规程的缓冲区太小,数据量一大就溢出。后来调整了N_TTY的缓冲区大小,问题解决。所以,别小看TTY子系统,它往往是性能瓶颈的隐藏点。
4.2 串口设备树配置:让内核认识你的串口
设备树,说白了就是给内核递的一张“硬件配置清单”。串口驱动要正常工作,设备树节点必须配对。我见过太多人因为设备树里一个属性写错,串口死活不工作。
一个典型的串口设备树节点长这样:
uart0: serial@fe201000 {
compatible = "arm,pl011", "arm,primecell";
reg = <0x0 0xfe201000 0x0 0x1000>;
interrupts = <0 29 4>;
clocks = <&uart0_clk>;
clock-names = "uartclk";
pinctrl-names = "default";
pinctrl-0 = <&uart0_pins>;
status = "okay";
};
咱们逐条拆解:
- compatible:驱动匹配的关键。内核通过这个字符串找到对应的串口驱动。比如
"arm,pl011"对应的是ARM PrimeCell UART驱动。 - reg:UART控制器的基地址和地址范围。我建议你对照芯片手册确认这个地址,写错了内核直接挂。
- interrupts:中断号、触发类型。串口收发数据靠中断驱动,这个配不对,数据收不到。
- clocks:串口的工作时钟。波特率计算全靠它。时钟频率不对,波特率就偏了。
- pinctrl:引脚复用配置。串口的TX、RX引脚必须配置成UART功能,否则信号出不去。
避坑指南:我曾经在某个项目里,设备树里忘了配pinctrl,结果串口TX引脚默认被配置成GPIO,输出电平死活不对。查了两天才发现是引脚复用没配。所以,pinctrl一定要检查。
另外,有些SoC的串口支持硬件流控(RTS/CTS)。如果需要,设备树里要加上uart-has-rtscts;属性。否则流控信号不会使能。
4.3 串口收发数据实战:从驱动到应用
好了,理论讲完,咱们上手实战。我以一个典型的ARM Linux平台为例,演示串口收发数据的完整流程。
4.3.1 驱动层:注册与初始化
串口驱动的入口是uart_register_driver和uart_add_one_port。内核里,串口驱动通常实现一个struct uart_ops结构体,里面包含:
.startup:打开串口时调用,初始化硬件、申请中断。.shutdown:关闭串口时调用,释放资源。.set_termios:设置波特率、数据位、停止位、校验位。.start_tx:启动发送。.stop_tx:停止发送。
我习惯在startup函数里做两件事:一是使能UART的接收中断,二是清空FIFO。这样能保证串口一打开就能收数据。
static int my_uart_startup(struct uart_port *port)
{
// 使能接收中断
writel(UART_RX_INT_EN, port->membase + UART_IMSC);
// 清空FIFO
writel(UART_FIFO_CLEAR, port->membase + UART_ICR);
return 0;
}
4.3.2 中断处理:数据收发的核心
串口中断处理函数是性能的关键。我见过一些新手在中断里做太多事,导致系统响应变慢。记住:中断里只做最必要的事——读数据、写数据、清中断标志。
static irqreturn_t my_uart_irq(int irq, void *dev_id)
{
struct uart_port *port = dev_id;
unsigned int status;
status = readl(port->membase + UART_MIS);
if (status & UART_RX_INT) {
// 读数据,放入TTY缓冲区
while (!(readl(port->membase + UART_FR) & UART_FR_RXFE)) {
unsigned char ch = readl(port->membase + UART_DR);
uart_insert_char(port, status, 0, ch, TTY_NORMAL);
}
// 清中断
writel(UART_RX_INT, port->membase + UART_ICR);
}
if (status & UART_TX_INT) {
// 发送数据
// ...
}
return IRQ_HANDLED;
}
个人经验:接收中断里,我建议用uart_insert_char而不是直接操作TTY缓冲区。这个函数会帮你处理线路规程的转换,比如回车换行、特殊字符过滤。省心不少。
4.3.3 应用层:打开、配置、读写
驱动写好了,应用层怎么用?标准做法是:
- 打开设备:
fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY); - 配置串口:用
tcsetattr设置波特率、数据位等。我建议用cfsetispeed和cfsetospeed分别设置输入输出波特率,虽然大多数情况两者一致。 - 读写数据:
read(fd, buf, len)和write(fd, buf, len)。注意:read是阻塞的,如果没有数据会一直等。可以用fcntl设置非阻塞模式。
int fd;
struct termios options;
fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY);
tcgetattr(fd, &options);
cfsetispeed(&options, B115200);
cfsetospeed(&options, B115200);
options.c_cflag |= (CLOCAL | CREAD);
options.c_cflag &= ~PARENB; // 无校验
options.c_cflag &= ~CSTOPB; // 1位停止位
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8; // 8位数据位
tcsetattr(fd, TCSANOW, &options);
// 发送数据
write(fd, "Hello UART", 10);
// 接收数据
char buf[256];
int n = read(fd, buf, sizeof(buf));
注意:应用层配置串口时,c_cflag里的CLOCAL和CREAD一定要置位。否则串口可能无法正常工作。我曾经因为忘了设CLOCAL,导致串口打开后modem信号检测不过,数据发不出去。
4.4 总结与避坑
串口驱动开发,说难不难,说简单也不简单。我总结几个关键点:
- 设备树:compatible、reg、interrupts、clocks、pinctrl,一个都不能少。
- 中断处理:轻量、高效,别在中断里做复杂操作。
- TTY子系统:理解它的分层结构,遇到问题知道从哪层查起。
- 应用层配置:termios结构体里的每个字段都有意义,别偷懒。
嗯,最后说一句:串口调试时,示波器是你的好朋友。我曾经靠示波器抓波形,发现某款芯片的UART TX引脚默认电平不对,原来是芯片内部上拉电阻没使能。这种问题,光看代码是看不出来的。
好了,这一章就到这儿。下一章咱们聊SPI驱动开发,那个比串口稍微复杂点,但套路是一样的。