基于serpi.c的Linux 16550 UART自定义驱动开发指南
发布时间:2026/9/11 3:00:11 作者:尧图编辑部 阅读量:1,286

简介一份面向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.c和serpi_write.c是用户态测试工具负责回环和单字节写出loads.sh、unloads.sh、disableserial.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_THRE、UART_FCR_ENABLE_FIFO这类符号。好处是迁移到内存映射平台时只需要把inb/outb换成readb/writeb寄存器定义不用改。16550 和 8250 最大的差异在接收路径。8250 没有 FIFO每个字节到达都触发一次中断115200bps 下 CPU 会被频繁打断16550 的发送和接收方向各有 16 字节 FIFOFCR可以控制 FIFO 使能、清除和接收触发阈值。触发阈值由FCR的 bit6-7 决定00 表示 1 字节01 表示 4 字节10 表示 8 字节11 表示 14 字节。调试串口建议用 8 字节噪声干扰大的工业总线建议用 1 字节因为报文短14 字节的触发值会把一次数据报拆成两批进入系统。表16550 寄存器偏移和关键位偏移寄存器DLAB1 时作用常用标志位0RBR/THR收发数据写为发送读为接收-1IER中断使能bit0 接收可用bit1 发送 FIFO 空2IIR/FCR写 FCR 控制 FIFOFCR bit0 使能 FIFObit2 清发送 FIFObit6-7 触发阈值3LCR线路参数与 DLABbit7 除数锁存bit0-1 数据位bit3 校验4MCR调制解调器控制bit4 回环模式5LSR线路状态bit0 数据就绪bit5 THREbit6 TEMT6MSRmodem 状态输入bit4 CTSbit0 Delta-CTS7SCR厂商保留可当普通存储-如果serpi.c的初始化代码把FCR写成0x01只打开了 FIFO 使能而没有指定触发阈值那么接收中断仍然会在每个字节到达时产生FIFO 形同虚设。我在调试串口时习惯把FCR写成0x07enable clear 接收 clear 发送再把触发级别通过宏暴露给模块参数。2.2 初始化里的波特率除数与 LCR 设置16550 波特率由uartclk /16 × baud得到除数。标准 PC 串口用 1.8432MHz 晶振115200 bps 的除数正好是 1如果你的板子用 22MHz 或者 APB 总线时钟比如PCLK 24MHz2400 bps 的除数是 625这个值超过 8 位必须分两字节写入DLM/DLL。serpi.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_BAUD和SERPI_PARITY宏我一般会让它们被module_param覆盖这样不同产品线只需要在loads.sh里加两个参数不用重新编译模块。2.3 注册成字符设备还是 uart_driver从serpi_test.c和serpi_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_add让open能转到serpi_open。注册设备类不是必须的但如果没有class_create和device_create新模块加载后/dev/serpi0不会自动出现只能靠loads.sh里的mknod手动造节点。我在工程里通常两个都保留insmod后先用device_create让 udev 生成节点再检查一次/dev/serpi0权限避免某些发行版对 0666 权限的 sysfs 有额外限制。选择裸字符设备的代价是失去 tty 层的termios、line discipline 和poll。官方 8250 驱动把这些功能全包在struct uart_port的ops里维护成本高但能力完整。serpi显然没有把重心放在这上面它的定位更像寄存器级教学工具。若要做正式产品正确的路径还是把serpi.c的寄存器操作迁到uart_ops-startup、stop_rx、tx_empty、set_termios这些回调里而不是继续用file_operations。3. 读写路径FIFO、LSR 轮询与用户态验证3.1 读路径先看 LSR 再看 RBR在没有任何中断和 DMA 的条件下读串口最可靠的方式是轮询LSR的 bit0UART_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_THREbit5表示发送保持寄存器为空不等于发送 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_TEMTbit6置 1否则最后几个字节会残留在 FIFO 里serpi_write.c如果只检查 THRE 不检查 TEMT就会在回环读取时发现少了数据。LSR状态位的整体判断对排查问题很有用bit名称含义0DR接收数据就绪1-4OE/PE/FE/BI溢出、校验、帧、break 错误5THRE发送保持寄存器空6TEMT发送器完全空含 FIFO每次读写前读一下LSR错误位高的时候要把读到的LSR和RBR一起做一次假读否则错误标志会持续影响后续数据。3.3 用 serpi_test 和 serpi_write 做回环验证serpi_test.c和serpi_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没有阻塞队列poll的default实现会永远报告可读导致select提前返回。要想让select和 read 的阻塞行为一致必须实现file_operations-poll并加入等待队列这是从教学代码走向实用驱动的下一个功课。注意回环模式下外部看不到波形这是判断是否进入回环的依据。如果发送端能发但接收端读不到先确认MCR_LOOP有没有在每次open时被重置。3.4 流控位在 serpi 里的取舍16550 的MCRbit1RTS和 bit0DTR可以被驱动控制MSRbit4CTS等引脚状态可以被读取但裸字符设备驱动的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唯一的结果是-EBUSY。disableserial.sh的存在就是为了避免这个冲突。常见做法是两层清理。第一层用setserial告诉内核这个端口已经不属于标准 UARTsudo setserial /dev/ttyS0 uart none sudo setserial /dev/ttyS0 port 0x0 irq 0uart none会把ttyS0的驱动类型改成 noneport 0x0 irq 0则清空资源防止serial8250再次绑定。第二层是从驱动模型上解绑echo ttyS0 | sudo tee /sys/class/tty/ttyS0/device/driver/unbind这只对挂载在platform或PNP总线上的设备有效。如果设备树里显式定义了serial3f8还需要在 DTS 里加上status disabled。实际上disableserial.sh里如果把参数写成了硬编码ttyS0在只有 COM2 生效的机器上跑会直接提示找不到设备所以我一般会在脚本里先用setserial -g列出现有端口再对扫描到的每一个 ttyS 执行释放。4.2 loads.sh 的加载逻辑与动态主设备号loads.sh这类名称一看就是为开发期设计的重复执行脚本。它要做的事很固定卸载旧模块加载新模块根据/proc/devices生成设备节点。#!/bin/sh MODULEserpi 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 firmmod前的2/dev/null是因为脚本第一次执行时模块根本不存在如果不忽略错误set -e会中断后续insmod。awk /serpi/ {print $1} /proc/devices抓取的是主设备号从alloc_chrdev_region分配得到的值不是固定的大部分机器上会在 240 附近但反复 insmod/rmmod 不保证一致。如果驱动注册了class_create和device_create加载之后/dev/serpi0会自动出现这段mknod只作为兼容老内核的 fallback。如果模块定义了地址和中断参数insmod时要显式传入sudo insmod serpi.ko base0x3f8 irq4base类型用ulong在 64 位平台上如果用int定义地址会被符号扩展造成inb访问失败。irq4对应第 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 -r和Makefile里的KDIRDevice or resource busyon request_region8250 驱动还占着 0x3F8先跑disableserial.sh确认/proc/ioports无冲突rmmod: Module serpi is in use有进程持着/dev/serpi0的 fd用lsof /dev/serpi0找到并结束占用进程segfaultin serpi_test用户态程序打开了错误设备节点检查/dev/serpi0的主设备号和cat /proc/devices是否一致invalid register access基址错inb访问了不存在的 I/O 空间insmod参数base是否为0x3f8注意不是内存地址卸载时最常见的坑是rmmod提示设备被占用。read/write进程即使只挂在那里没有数据file_operations的release没执行前模块引用计数就不会归零。此时不要直接rmmod -f应该先运行lsof /dev/serpi0 | grep serpi把进程 kill 掉再重试。如果serpi.c的release里漏了iounmap或者free_irq模块卸载虽然能成功但残留的 IRQ handler 会在下一次加载时触发irq handler type mismatch到时候只能重启清状态。unloads.sh在标准做法里还会多做一步把disableserial.sh还原即重新绑定ttyS0到serial8250保证下一次系统启动时串口恢复默认行为。5. 把轮询改成中断驱动一个可下板的 16550 进阶改动5.1 注册中断取代忙等前面的serpi_read和serpi_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 时故意传一个错误的irq255加载会失败并报Invalid argument换回正确中断号后重复serpi_test回环观察dmesg里没有unexpected IRQ同时top中该进程的 CPU 占用降到接近 0%。把这段改动收进hacks目录后你已经有了一条从轮询到中断的完整迁移路径接下来再往前一步就是给发送路径加 tasklet 或工作队列把 FIFO 压榨到接近 14 字节触发级别的吞吐量。本文还有配套的精品资源点击获取