news 2026/9/11 3:00:08

基于serpi.c的Linux 16550 UART自定义驱动开发指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于serpi.c的Linux 16550 UART自定义驱动开发指南

简介:一份面向Linux内核驱动开发与嵌入式串口通信学习者的16550 UART驱动源码包。资源共11个文件,以C源文件、Shell脚本、头文件为主,附Makefile、说明文档及编译好的ko模块,压缩包仅13KB,结构精简,适合通读剖析。目前已有1265人学习浏览。内容围绕16550驱动在Linux下的实现展开,涵盖FIFO缓冲、波特率配置、中断处理、CTS/RTS与XON/XOFF流控制、串口子系统注册及读写流程等关键知识点;源码仓库中3个C文件与2个头文件构成驱动主体,3个Shell脚本可辅助加载、卸载模块,README和Makefile便于二次构建与实验。读者可借助完整源码理解内核驱动与硬件交互机制,并直接在目标环境调试,是嵌入式开发者、驱动学习者和系统调试人员快速上手的实用参考。

1. 把 8250 接管之前:为什么需要一份 16550 自定义驱动

你能搜到16550_serpi-master,大概率不是想看系统自带 8250 驱动怎么运行,而是想绕过它。Linux 发行版默认把 COM1 交给serial8250管着,但当你需要自定义波特率除数、调整 16550 的 FIFO 触发水位、或者在用户态直接控制 RTS/CTS 时,默认驱动反而成了碍事的黑盒。16550_serpi-master这套代码把问题拆得很干净:serpi.c是内核态驱动,直接操作 UART16550 的 DLAB/THR/LSR/MCR 寄存器;serpi_test.cserpi_write.c是用户态测试工具,负责回环和单字节写出;loads.shunloads.shdisableserial.sh则把模块加载、卸载和释放系统串口资源这些容易踩坑的动作固化成脚本。它适合两类人:一类是刚写内核驱动的嵌入式工程师,想在最小框架里建立“寄存器位到实际波形”的对应关系;另一类是在老平台上做串口扩展的熟手,需要一份不会引入 tty 层心智负担的参考底稿。

2. 16550 寄存器地图与 serpi.c 的初始化路径

2.1 从 serial_reg.h 看 UART16550 寄存器布局

serial_reg.h是内核include/uapi/linux/serial_reg.h的原样拷贝或者裁剪版本,它把 16550 的 8 个寄存器偏移和位掩码统一命名。serpi.c引用它,意味着驱动内部不写裸地址,而是用UART_LSR_THREUART_FCR_ENABLE_FIFO这类符号。好处是迁移到内存映射平台时,只需要把inb/outb换成readb/writeb,寄存器定义不用改。

16550 和 8250 最大的差异在接收路径。8250 没有 FIFO,每个字节到达都触发一次中断,115200bps 下 CPU 会被频繁打断;16550 的发送和接收方向各有 16 字节 FIFO,FCR可以控制 FIFO 使能、清除和接收触发阈值。触发阈值由FCR的 bit6-7 决定:00 表示 1 字节,01 表示 4 字节,10 表示 8 字节,11 表示 14 字节。调试串口建议用 8 字节,噪声干扰大的工业总线建议用 1 字节,因为报文短,14 字节的触发值会把一次数据报拆成两批进入系统。

表:16550 寄存器偏移和关键位

偏移寄存器DLAB=1 时作用常用标志位
0RBR/THR收发数据(写为发送,读为接收)-
1IER中断使能bit0 接收可用;bit1 发送 FIFO 空
2IIR/FCR写 FCR 控制 FIFOFCR bit0 使能 FIFO,bit2 清发送 FIFO,bit6-7 触发阈值
3LCR线路参数与 DLABbit7 除数锁存,bit0-1 数据位,bit3 校验
4MCR调制解调器控制bit4 回环模式
5LSR线路状态bit0 数据就绪,bit5 THRE,bit6 TEMT
6MSRmodem 状态输入bit4 CTS,bit0 Delta-CTS
7SCR厂商保留,可当普通存储-

如果serpi.c的初始化代码把FCR写成0x01,只打开了 FIFO 使能而没有指定触发阈值,那么接收中断仍然会在每个字节到达时产生,FIFO 形同虚设。我在调试串口时习惯把FCR写成0x07(enable + clear 接收 + clear 发送),再把触发级别通过宏暴露给模块参数。

2.2 初始化里的波特率除数与 LCR 设置

16550 波特率由uartclk /(16 × baud)得到除数。标准 PC 串口用 1.8432MHz 晶振,115200 bps 的除数正好是 1;如果你的板子用 22MHz 或者 APB 总线时钟,比如PCLK = 24MHz,2400 bps 的除数是 625,这个值超过 8 位,必须分两字节写入DLM/DLLserpi.c里常见实现是:

static void serpi_set_baud(void __iomem *base, unsigned int clk, unsigned int baud) { unsigned int divisor = DIV_ROUND_CLOSEST(clk, 16 * baud); unsigned char lcr; lcr = inb(base + UART_LCR); outb(lcr | UART_LCR_DLAB, base + UART_LCR); /* 打开除数锁存 */ outb(divisor & 0xff, base + UART_DLL); /* 低字节 */ outb((divisor >> 8) & 0xff, base + UART_DLM); /* 高字节 */ outb(lcr, base + UART_LCR); /* 正常模式 */ }

DIV_ROUND_CLOSEST会做四舍五入,避免 9600 bps 时出现 11.97 这类小数被截成 11。如果平台时钟不是标准值,写完除数后最好在控制台打印实际波特率偏差,常用判断标准是偏差小于 ±2%,超过 2% 就会出现偶发错帧。

配置数据位、校验位、停止位的 LCR 写法,在serpi.c里通常被封装成一个类似serpi_set_line的函数:

static void serpi_set_line(void __iomem *base, unsigned int databits, unsigned int parity, unsigned int stopbits) { unsigned char lcr = UART_LCR_WLEN8; /* 8 数据位 */ if (databits == 7) lcr &= ~UART_LCR_WLEN8; /* 清掉 bit1,保留 bit0,得到 7 位 */ if (parity != 0) { lcr |= UART_LCR_PARITY; /* bit3 使能校验 */ if (parity == 2) /* 2 表示偶校验 */ lcr |= UART_LCR_EPAR; /* bit4 置 1 */ } if (stopbits == 2) lcr |= UART_LCR_STOP; /* bit2 置 1 */ outb(lcr, base + UART_LCR); }

这个函数的参数几乎对应termios里的c_cflag。真正的 uart_driver 会把调用时机放在set_termios回调中,而serpi.c因为是裸字符设备,只能直接由用户态通过 ioctl 或 sysfs 触发。如果在serpi.h里留了SERPI_BAUDSERPI_PARITY宏,我一般会让它们被module_param覆盖,这样不同产品线只需要在loads.sh里加两个参数,不用重新编译模块。

2.3 注册成字符设备还是 uart_driver

serpi_test.cserpi_write.c的存在可以判断,这两个工具走的是open/read/write,不经过 termios,因此serpi.c不是完整意义上的 uart_driver,而是更轻量的字符设备驱动。它的主干代码是:

static const struct file_operations serpi_fops = { .owner = THIS_MODULE, .open = serpi_open, .read = serpi_read, .write = serpi_write, .release = serpi_release, }; static int __init serpi_init(void) { int ret; ret = alloc_chrdev_region(&dev->devno, 0, 1, "serpi"); if (ret < 0) return ret; cdev_init(&dev->cdev, &serpi_fops); dev->cdev.owner = THIS_MODULE; ret = cdev_add(&dev->cdev, dev->devno, 1); if (ret < 0) goto out_unregister; return 0; }

alloc_chrdev_region分配主设备号后,/proc/devices里会多出一行数字加serpi的记录;cdev_addopen能转到serpi_open。注册设备类不是必须的,但如果没有class_createdevice_create,新模块加载后/dev/serpi0不会自动出现,只能靠loads.sh里的mknod手动造节点。我在工程里通常两个都保留:insmod后先用device_create让 udev 生成节点,再检查一次/dev/serpi0权限,避免某些发行版对 0666 权限的 sysfs 有额外限制。

选择裸字符设备的代价是失去 tty 层的termios、line discipline 和poll。官方 8250 驱动把这些功能全包在struct uart_portops里,维护成本高但能力完整。serpi显然没有把重心放在这上面,它的定位更像寄存器级教学工具。若要做正式产品,正确的路径还是把serpi.c的寄存器操作迁到uart_ops->startupstop_rxtx_emptyset_termios这些回调里,而不是继续用file_operations

3. 读写路径:FIFO、LSR 轮询与用户态验证

3.1 读路径:先看 LSR 再看 RBR

在没有任何中断和 DMA 的条件下,读串口最可靠的方式是轮询LSR的 bit0(UART_LSR_DR)。该位为 1 说明接收 FIFO 或接收保持寄存器里有至少一个字节,之后inb(base + UART_RX)取回数据。用代码表示为:

static ssize_t serpi_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { struct serpi_dev *dev = filp->private_data; unsigned char ch; int ret; while (!(inb(dev->base + UART_LSR) & UART_LSR_DR)) { if (filp->f_flags & O_NONBLOCK) return -EAGAIN; cpu_relax(); } ch = inb(dev->base + UART_RX); ret = copy_to_user(buf, &ch, 1); if (ret) return -EFAULT; return 1; }

这段代码作为教学没问题,但有三个边界条件。第一,一次 read 只返回单字节,count形同虚设;可以用循环把RBR读到count为止,但每次都要再次检查LSR_DR。第二,cpu_relax()在单核上不会触发调度,对端如果停止发送,read 会把整个 CPU 占满;我一般会加一个msleep_interruptible(1)作为超时兜底。第三,O_NONBLOCK的要义是“一旦没有数据立刻返回”,所以应该把-EAGAIN提前到第一次读LSR之前判断,而不是放在循环里反复检查。

serpi_test在回环模式下,会先把数据写入发送 FIFO,再进入serpi_read。由于回环路径没有外部信号延迟,read 通常一遍就能读到数据。如果读出来是 0x00 或者超时,就要检查MCR的 loopback 位和FCR的清除位是否在初始化时被改写。

3.2 写路径:THRE 标志与 FIFO 水位的区别

写路径上,UART_LSR_THRE(bit5)表示发送保持寄存器为空,不等于发送 FIFO 清空。16550 使能 FIFO 后,CPU 写 THR 实际上是把数据推进发送 FIFO,只要 FIFO 不满,THRE 就一直为 1。因此在批量发送场景中,按 THRE 轮询会连续把 16 字节全塞进 FIFO,写完后真正发送完还需要等发送移位寄存器。

一个简化版的写函数:

static ssize_t serpi_write(struct file *filp, const char __user *buf, size_t count, loff_t *off) { struct serpi_dev *dev = filp->private_data; unsigned char data; size_t written = 0; while (written < count) { if (!(inb(dev->base + UART_LSR) & UART_LSR_THRE)) continue; if (copy_from_user(&data, buf + written, 1)) return -EFAULT; outb(data, dev->base + UART_TX); written++; } return written; }

如果确认对端不会做流控,可以保留这种忙等。但内核驱动里操作copy_from_user每字节一次,页错误路径会对性能有 <1% 的影响,所以实际改造时可以先把整个用户缓冲区copy_from_user到驱动维护的tx_buf,再用while往 THR 填。关闭设备前必须等待UART_LSR_TEMT(bit6)置 1,否则最后几个字节会残留在 FIFO 里,serpi_write.c如果只检查 THRE 不检查 TEMT,就会在回环读取时发现少了数据。

LSR状态位的整体判断对排查问题很有用:

bit名称含义
0DR接收数据就绪
1-4OE/PE/FE/BI溢出、校验、帧、break 错误
5THRE发送保持寄存器空
6TEMT发送器完全空(含 FIFO)

每次读写前读一下LSR,错误位高的时候要把读到的LSRRBR一起做一次假读,否则错误标志会持续影响后续数据。

3.3 用 serpi_test 和 serpi_write 做回环验证

serpi_test.cserpi_write.c在这个工程里就是用户态验证入口。测试流程并不复杂:先加载模块并创建设备节点,然后把 UART 切到回环模式,最后跑测试程序回读发送数据。

sudo ./loads.sh sudo /bin/sh -c 'echo 1 > /sys/devices/virtual/serpi/serpi0/loopback' 2>/dev/null || true sudo ./serpi_test

如果没有暴露loopback的 sysfs,可以在serpi_write里先用命令把设备/dev/serpi0open 后写一个 magic 字节,再用serpi_test读回来。这里给一个我在调试时用的时序:先把MCR的 bit4 置 1,然后清空 FIFO,再写发送 FIFO。

static void serpi_loopback_enable(struct serpi_dev *dev) { unsigned char mcr = inb(dev->base + UART_MCR); outb(mcr | UART_MCR_LOOP, dev->base + UART_MCR); }

UART_MCR_LOOP使能后,芯片内部 TX 和 RX 短接,外部引脚保持空闲,电气上不会影响相邻设备。如果你在串口外用示波器观察,会看不到任何波形,这是回环测试最直接的判断依据。测试程序如果用了select等待读事件,需要注意:serpi_read没有阻塞队列,polldefault实现会永远报告可读,导致select提前返回。要想让select和 read 的阻塞行为一致,必须实现file_operations->poll并加入等待队列,这是从教学代码走向实用驱动的下一个功课。

注意:回环模式下外部看不到波形,这是判断是否进入回环的依据。如果发送端能发但接收端读不到,先确认MCR_LOOP有没有在每次open时被重置。

3.4 流控位在 serpi 里的取舍

16550 的MCRbit1(RTS)和 bit0(DTR)可以被驱动控制,MSRbit4(CTS)等引脚状态可以被读取,但裸字符设备驱动的read/write框架里没有专门的流控路径。serpi.c如果要支持 CTS/RTS,通常的做法是给ioctl增加一个SERPI_SET_RTS命令,或者在write前用inb(base + UART_MSR) & UART_MSR_CTS做软轮询。反过来,软件流控 XON/XOFF 本身就是链路协议,和 UART 寄存器无关。资源里没有单独列出flow.c,说明作者大概率没有纳入这些,测试时若接外部设备,需要先用短路线短接 TX/RX,别指望serpi自动处理握手。

4. 加载与卸载:loads.sh、disableserial.sh 与 dmesg 排错

4.1 为什么第一件事是释放 8250 占用的 I/O 端口

serial8250驱动几乎是所有 x86 Linux 发行版默认模块,它会在启动时探测传统 COM 口并用request_region标记端口。serpi.c作为自定义驱动,如果想在 0x3F8 上request_region,唯一的结果是-EBUSYdisableserial.sh的存在就是为了避免这个冲突。

常见做法是两层清理。第一层用setserial告诉内核这个端口已经不属于标准 UART:

sudo setserial /dev/ttyS0 uart none sudo setserial /dev/ttyS0 port 0x0 irq 0

uart none会把ttyS0的驱动类型改成 none,port 0x0 irq 0则清空资源,防止serial8250再次绑定。第二层是从驱动模型上解绑:

echo 'ttyS0' | sudo tee /sys/class/tty/ttyS0/device/driver/unbind

这只对挂载在platformPNP总线上的设备有效。如果设备树里显式定义了serial@3f8,还需要在 DTS 里加上status = "disabled"。实际上,disableserial.sh里如果把参数写成了硬编码ttyS0,在只有 COM2 生效的机器上跑会直接提示找不到设备,所以我一般会在脚本里先用setserial -g列出现有端口,再对扫描到的每一个 ttyS 执行释放。

4.2 loads.sh 的加载逻辑与动态主设备号

loads.sh这类名称一看就是为开发期设计的重复执行脚本。它要做的事很固定:卸载旧模块,加载新模块,根据/proc/devices生成设备节点。

#!/bin/sh MODULE=serpi sudo rmmod $MODULE 2>/dev/null sudo insmod $MODULE.ko major=$(awk '/serpi/ {print $1}' /proc/devices) if [ -n "$major" ]; then sudo rm -f /dev/serpi0 sudo mknod /dev/serpi0 c $major 0 sudo chmod 666 /dev/serpi0 fi

rmmod前的2>/dev/null是因为脚本第一次执行时模块根本不存在,如果不忽略错误,set -e会中断后续insmodawk '/serpi/ {print $1}' /proc/devices抓取的是主设备号,从alloc_chrdev_region分配得到的值不是固定的,大部分机器上会在 240 附近,但反复 insmod/rmmod 不保证一致。如果驱动注册了class_createdevice_create,加载之后/dev/serpi0会自动出现,这段mknod只作为兼容老内核的 fallback。

如果模块定义了地址和中断参数,insmod时要显式传入:

sudo insmod serpi.ko base=0x3f8 irq=4

base类型用ulong,在 64 位平台上如果用int定义,地址会被符号扩展造成inb访问失败。irq=4对应第 2 个传统串口常用的 IRQ,有些嵌入式板卡的 UART 会共用 GPIO 模拟 IRQ,此时request_irq必须带上IRQF_SHARED,否则和别的设备共用中断线会直接返回-EBUSY。这些细节在dmesg里都有明确提示,关键是你得先开启串口驱动的 debug 日志,我一般会在loads.sh里加一行echo 'file serpi.c +p' > /sys/kernel/debug/dynamic_debug/control

4.3 卸载失败与 dmesg 中的关键信息

模块加载阶段的错误在 dmesg 里最显眼,但卸载阶段的问题更隐蔽。常见的错误和定位手段可以按下面这张表来对照:

dmesg 或命令输出原因对策
No such file or directoryon insmodserpi.ko编译架构与运行内核不一致检查uname -rMakefile里的KDIR
Device or resource busyon request_region8250 驱动还占着 0x3F8先跑disableserial.sh,确认/proc/ioports无冲突
rmmod: Module serpi is in use有进程持着/dev/serpi0的 fdlsof /dev/serpi0找到并结束占用进程
segfaultin serpi_test用户态程序打开了错误设备节点检查/dev/serpi0的主设备号和cat /proc/devices是否一致
invalid register access基址错,inb访问了不存在的 I/O 空间insmod参数base是否为0x3f8,注意不是内存地址

卸载时最常见的坑是rmmod提示设备被占用。read/write进程即使只挂在那里没有数据,file_operationsrelease没执行前,模块引用计数就不会归零。此时不要直接rmmod -f,应该先运行lsof /dev/serpi0 | grep serpi,把进程 kill 掉再重试。如果serpi.crelease里漏了iounmap或者free_irq,模块卸载虽然能成功,但残留的 IRQ handler 会在下一次加载时触发irq handler type mismatch,到时候只能重启清状态。unloads.sh在标准做法里还会多做一步:把disableserial.sh还原,即重新绑定ttyS0serial8250,保证下一次系统启动时串口恢复默认行为。

5. 把轮询改成中断驱动:一个可下板的 16550 进阶改动

5.1 注册中断取代忙等

前面的serpi_readserpi_write都依赖忙等,这对教学足够,但对真实硬件不够:接收方长时间不发送,CPU 就会被cpu_relax()卡住。16550 的 IER 寄存器提供了两个最常用的中断源,bit0 表示接收数据或接收超时,bit1 表示发送保持寄存器空。配合等待队列,读路径可以改成典型的阻塞式 I/O。

serpi_dev中加一个等待队列头:

struct serpi_dev { void __iomem *base; unsigned int irq; struct cdev cdev; dev_t devno; wait_queue_head_t rq; /* 接收唤醒队列 */ };

注册 IRQ 时要用request_irq(dev->irq, serpi_irq, IRQF_SHARED, "serpi", dev)IRQF_SHARED在传统 ISA 串口上几乎是必需的,因为中断控制器里的同一个 IRQ 可能被多个设备共享。中断处理函数先读IIR的最低 4 位,如果是UART_IIR_NO_INT,说明这个中断不是 16550 产生的,直接返回IRQ_NONE,让共享设备继续处理。

5.2 接收中断、发送中断的防风暴处理

接收中断路径只有两行关键代码:

static irqreturn_t serpi_irq(int irq, void *dev_id) { struct serpi_dev *dev = dev_id; unsigned char iir; iir = inb(dev->base + UART_IIR) & 0x0f; if (iir == UART_IIR_NO_INT) return IRQ_NONE; if ((iir & UART_IIR_RDI) || (iir & UART_IIR_RX_TIMEOUT)) wake_up_interruptible(&dev->rq); if (iir & UART_IIR_THRI) { /* 发送 FIFO 有空位,适合在低水位时继续灌数据 */ disable_irq_nosync(dev->irq); } return IRQ_HANDLED; }

wake_up_interruptible会把阻塞在wait_event_interruptible的 read 进程唤醒,然后由驱动或用户态继续从RBR取走 FIFO 里的数据。发送中断UART_IIR_THRI必须小心:只要 IER 的 bit1 为 1,发送 FIFO 一有空间就会不停触发中断,如果没有数据可发,CPU 会陷入中断风暴。所以发送中断只应该在发送队列还有 pending 数据时打开,发完后立刻关闭。

读侧从忙等改成:

ret = wait_event_interruptible(dev->rq, inb(dev->base + UART_LSR) & UART_LSR_DR); if (ret) return -ERESTARTSYS;

wait_event_interruptible让进程在条件不满足时进入睡眠,不会在单核上烧死 CPU。注意它的条件表达式会在每次唤醒时重新读LSR,所以中断处理函数里并不需要真的把RBR数据取出来,驱动只需唤醒一个读者即可。为验证中断是否正确,可以在 insmod 时故意传一个错误的irq=255,加载会失败并报Invalid argument;换回正确中断号后,重复serpi_test回环,观察dmesg里没有unexpected IRQ,同时top中该进程的 CPU 占用降到接近 0%。把这段改动收进hacks目录后,你已经有了一条从轮询到中断的完整迁移路径,接下来再往前一步就是给发送路径加 tasklet 或工作队列,把 FIFO 压榨到接近 14 字节触发级别的吞吐量。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 2:58:56

AI编程真实水位线:30天后你该补哪三块能力短板

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 2:58:38

MBA论文写作AI工具实测:从文献检索到降重的完整方案

1. 测评背景与维度设计 1.1 为什么会有这篇全维度测评 先说背景。我自己当年写MBA毕业论文的时候&#xff0c;白天上班晚上写论文&#xff0c;连续三个月几乎没有完整休息日。最崩溃的不是没思路&#xff0c;而是思路明明很清楚&#xff0c;但写到文献综述、理论框架、数据分析…

作者头像 李华
网站建设 2026/9/11 2:57:50

Intel工业网卡技术解析:确定性通信的硬件根基

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 2:52:29

基于FastAPI与订单状态机的虚拟商品自动发货系统实践

1. 项目定位与整体设计思路先说结论&#xff1a;这个项目解决的是“没有营业执照、没有企业资质、也不想走第三方支付平台审核”的卖家&#xff0c;如何低成本搭建一个能自动发货、能管理订单的虚拟商品交易系统。我做这个系统时&#xff0c;最核心的取舍就是&#xff1a;不接微…

作者头像 李华
网站建设 2026/9/11 2:52:18

STM32驱动TT马达实战:从物理特性到电流闭环控制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华