1. 串口驱动在Android体系中的位置与核心价值
1.1 为什么一个串口驱动值得单独拿出来讲
做Android底层的人都有一个共识:越靠近硬件的代码,越不容易被上层开发者关注,但一旦出问题,排查成本极高。msm_serial.c就是这样一个存在。它属于高通平台Linux内核中的串口驱动实现,负责管理SoC上的UART控制器,让设备能通过串口与外部模块、调试工具、工业设备进行数据交互。
很多人第一次接触这个文件,是因为项目里串口通信不稳定——数据丢包、波特率对不上、打开设备节点报错、DMA传输卡死。搜遍论坛,答案零散,最后还是要回到驱动源码里找原因。这篇文章就是把我自己在实际项目中反复调试msm_serial.c的经验整理出来,从驱动架构、关键数据结构、中断与DMA处理、设备树配置到常见问题排查,尽量讲透。
适合阅读的人群包括:Android BSP工程师、驱动开发人员、嵌入式Linux开发者,以及需要在高通平台上做串口通信应用但总被底层问题卡住的开发者。即使你之前没读过内核串口子系统代码,我也会从必要的背景知识讲起,保证能跟上。
1.2 高通串口驱动的基本架构
高通平台的串口驱动并不是一个孤立的文件,它建立在Linux内核串口框架(serial core)之上。整个调用关系大致是这样的:
- 应用层通过
/dev/ttyHSLx或/dev/ttyMSMx设备节点访问串口 - TTY子系统负责线路规程、缓冲管理
- 串口核心层(
serial_core.c)提供通用接口 msm_serial.c实现高通平台特有的硬件操作
msm_serial.c中定义了一个uart_ops结构体,这是驱动与串口核心层之间的契约。里面包含的函数指针决定了驱动如何初始化硬件、发送数据、接收数据、设置波特率、处理中断等。理解这个结构体,基本就理解了驱动的骨架。
static struct uart_ops msm_serial_ops = { .tx_empty = msm_tx_empty, .set_mctrl = msm_set_mctrl, .get_mctrl = msm_get_mctrl, .stop_tx = msm_stop_tx, .start_tx = msm_start_tx, .stop_rx = msm_stop_rx, .tx_empty = msm_tx_empty, .set_termios = msm_set_termios, .startup = msm_startup, .shutdown = msm_shutdown, ... };每个函数指针背后都对应着对UART寄存器的读写操作。比如msm_start_tx会去检查发送FIFO是否可写,然后把数据推入FIFO或启动DMA传输。这些操作看似简单,但实际调试时,一个寄存器的位定义理解错了,就可能出现数据发不出去或者中断风暴。
2. 核心数据结构与硬件抽象层解析
2.1 msm_port结构体:驱动的中枢
msm_serial.c中最重要的数据结构是struct msm_port。它保存了一个串口端口的所有运行时信息,包括寄存器基地址、时钟、中断号、DMA通道、FIFO状态等。可以把它理解成驱动为每个UART实例维护的“档案袋”。
struct msm_port { struct uart_port uart; struct clk *clk; struct clk *pclk; unsigned int imr; unsigned int *gsbi_base; ... struct msm_dma tx_dma; struct msm_dma rx_dma; };这里有几个关键点值得展开。uart是内嵌的通用串口端口结构,串口核心层通过它来识别这个设备。clk和pclk分别是UART控制器的功能时钟和总线时钟,高通平台对时钟管理非常严格,时钟没使能就去读寄存器,轻则读到全0,重则直接挂死。imr保存了中断掩码寄存器的值,因为高通UART的中断控制不是简单的读写,需要读-改-写来避免误清其他中断位。
tx_dma和rx_dma是DMA相关的描述结构。在高通平台上,串口支持DMA模式传输,特别是在高波特率下,用CPU轮询或中断方式收发数据会导致大量中断开销,DMA可以显著降低CPU占用。但DMA的配置也更容易出错,后面会专门讲。
2.2 寄存器映射与位域操作
高通UART控制器的寄存器布局和标准8250有相似之处,但也有自己的扩展。msm_serial.c中定义了一系列宏来访问这些寄存器:
#define UART_DM_MR1 0x00 #define UART_DM_MR2 0x04 #define UART_DM_SR 0x08 #define UART_DM_CSR 0x0C #define UART_DM_CR 0x10 ...这些寄存器通过msm_read和msm_write函数访问,内部会处理端序和位宽问题。实际调试时,我习惯用dev_dbg把关键寄存器的值打印出来,尤其是在set_termios和中断处理函数里。比如波特率设置不生效,先看UART_DM_CSR里的时钟选择位和分频值对不对。
位域操作是另一个容易踩坑的地方。高通UART的某些寄存器是写1清除中断,有些是写1使能,还有些是保留位必须保持原值。我在一个项目里遇到过中断只触发一次就再也不来的情况,最后发现是中断清除寄存器的写法不对——应该先读UART_DM_SR判断中断源,再往UART_DM_CR写对应的清除位,顺序反了就会丢中断。
2.3 设备树中的串口节点配置
现代高通平台都使用设备树来描述硬件。串口节点通常在msm-xxx.dtsi中定义,板级dts里做覆盖。一个典型的串口节点长这样:
uart2: serial@78b0000 { compatible = "qcom,msm-uartdm-v1.4"; reg = <0x78b0000 0x200>; interrupts = <0 108 0>; clocks = <&clock_gcc clk_gcc_blsp1_uart2_apps_clk>, <&clock_gcc clk_gcc_blsp1_uart2_ahb_clk>; clock-names = "core", "iface"; pinctrl-names = "default", "sleep"; pinctrl-0 = <&uart2_active>; pinctrl-1 = <&uart2_sleep>; status = "okay"; };compatible属性决定了驱动匹配哪个of_device_id。msm_serial.c里注册了多个兼容字符串,对应不同版本的UART控制器。如果设备树里的compatible和驱动里的不匹配,驱动根本不会probe,设备节点也不会出现。我见过有人改了设备树但忘了同步修改驱动里的匹配表,结果串口死活出不来。
pinctrl配置也很关键。高通平台的引脚复用非常灵活,一个物理引脚可以配置成GPIO、UART、SPI、I2C等。如果pinctrl没配对,UART的TX和RX引脚可能还处于GPIO模式,自然无法通信。调试时可以用cat /sys/kernel/debug/pinctrl/下的文件来确认引脚当前的功能复用状态。
3. 中断处理与DMA传输的实操细节
3.1 中断处理流程拆解
msm_serial.c的中断处理函数msm_handle_rx和msm_handle_tx是数据收发的核心。中断触发后,驱动需要先读取状态寄存器判断中断类型,然后分别处理接收和发送。
接收路径大致是这样的:UART收到数据后触发RX中断,驱动从接收FIFO中读取数据,放入TTY层的缓冲区,然后唤醒等待数据的进程。发送路径则是:当发送FIFO有空位时触发TX中断,驱动从TTY层的发送缓冲区取数据写入FIFO。
这里有一个关键细节:高通UART的接收FIFO深度有限,通常是64字节或128字节。如果中断处理太慢,FIFO会溢出,导致数据丢失。我在一个高速数据采集项目里就遇到过这个问题,波特率115200时没问题,提到921600就开始丢包。后来把接收中断的优先级提高,并且在中断处理里一次性把FIFO读空,问题才解决。
static irqreturn_t msm_handle_rx(int irq, void *dev) { struct msm_port *msm_port = dev; struct uart_port *port = &msm_port->uart; unsigned int sr; ... sr = msm_read(port, UART_DM_SR); if (sr & UART_DM_SR_RXRDY) { do { ch = msm_read(port, UART_DM_RF); uart_insert_char(port, sr, UART_DM_SR_RXRDY, ch, TTY_NORMAL); sr = msm_read(port, UART_DM_SR); } while (sr & UART_DM_SR_RXRDY); } tty_flip_buffer_push(&port->state->port); ... }注意do-while循环那里,必须一直读到FIFO为空,否则残留的数据要等下一次中断才能处理,增加了延迟。但也不能死循环,如果硬件异常导致RXRDY一直置位,就会卡在中断里。实际代码里通常会加一个最大读取次数限制。
3.2 DMA模式配置与常见故障
高波特率场景下,DMA模式几乎是必须的。msm_serial.c中DMA的初始化在msm_startup里完成,通过msm_request_dma申请DMA通道,然后配置传输描述符。
DMA配置有几个关键参数:传输方向、数据宽度、突发长度、FIFO水位。突发长度设置得太小,DMA效率低;设置得太大,可能超过FIFO容量导致错误。我一般会参考高通文档里的推荐值,再根据实际测试微调。
DMA模式最常见的故障是传输卡死。表现是串口突然不收不发,但设备节点还在,也没有报错。排查思路是:
- 先看DMA通道状态,
cat /sys/kernel/debug/dmaengine/下有没有异常 - 检查DMA中断有没有正常触发,可以在中断处理里加打印
- 确认DMA缓冲区的物理地址是否正确,cache一致性有没有处理
有一次我遇到DMA发送完成中断不来,查了半天发现是DMA描述符的cookie没有正确更新。高通DMA引擎用cookie来标识传输完成,如果驱动没有在提交传输时保存cookie,或者在中断处理里比较cookie的逻辑有误,就会导致完成事件丢失。
注意:DMA缓冲区的cache一致性是高通平台上的高频问题。如果DMA缓冲区用的是普通内存,CPU写完后必须调用
dma_sync_single_for_device来刷新cache,否则DMA引擎可能读到旧数据。接收方向则相反,DMA写完后要调用dma_sync_single_for_cpu让CPU看到新数据。
3.3 波特率计算与时钟配置
波特率设置是串口驱动里最容易出错的部分之一。高通UART的波特率由输入时钟分频得到,计算公式大致是:
波特率 = 输入时钟 / (分频系数 * 过采样率)msm_serial.c的msm_set_termios函数里会根据用户请求的波特率反推分频系数。但这里有个坑:不是所有波特率都能精确分频得到。比如输入时钟是7.3728MHz,请求波特率是115200,分频系数是64,刚好整除。但如果请求的是123456这种非标准波特率,算出来的分频系数可能不是整数,实际波特率就会有偏差。
我在一个项目里遇到过对方设备要求波特率是250000,但怎么配都通信不上。后来用示波器量了实际波形,发现实际波特率偏差超过了3%,对方设备容忍度不够。解决办法是换一个更合适的输入时钟源,让分频系数能更接近整数。
static unsigned int msm_get_baud_rate(struct uart_port *port, unsigned int baud) { unsigned int clk = msm_port->clk_rate; unsigned int div; ... div = clk / (baud * 16); if (div < 1) div = 1; return clk / (div * 16); }实际代码里还会考虑过采样率是16还是4,高通UART支持不同的过采样模式,高波特率下用低过采样率可以减少分频误差。这些细节在msm_set_termios里都有体现,值得仔细读一遍。
4. 常见问题排查与实战经验汇总
4.1 串口设备节点出不来的排查路径
设备节点出不来是最常见的问题,排查可以按以下顺序进行:
| 排查步骤 | 检查内容 | 常用命令 |
|---|---|---|
| 1 | 设备树节点是否使能 | ls /proc/device-tree/下找对应节点 |
| 2 | 驱动是否匹配成功 | `dmesg |
| 3 | 时钟是否使能 | cat /sys/kernel/debug/clk/clk_summary |
| 4 | pinctrl是否配置 | cat /sys/kernel/debug/pinctrl/pinmux-pins |
| 5 | 设备节点是否创建 | ls /dev/ttyHSL* |
我遇到过设备树节点写了但status还是disabled的情况,这种最隐蔽,因为dts编译不会报错,但驱动不会probe。还有一种情况是compatible字符串拼写错误,比如把qcom,msm-uartdm-v1.4写成了qcom,msm-uart-dm-v1.4,多了一个横杠,驱动匹配不上。
4.2 数据收发异常的典型原因
数据收发异常的表现多种多样,我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 发送正常接收无数据 | RX引脚复用错误 | 检查pinctrl,量RX引脚波形 |
| 接收正常发送无数据 | TX引脚复用错误 | 检查pinctrl,量TX引脚波形 |
| 数据乱码 | 波特率不匹配 | 示波器量实际波特率 |
| 偶发丢包 | FIFO溢出或中断延迟 | 提高中断优先级,增大FIFO水位 |
| 高波特率下丢包 | DMA未启用或配置错误 | 检查DMA通道和描述符 |
| 打开设备报错 | 时钟未使能或引脚冲突 | 检查clk_summary和pinctrl |
乱码问题我踩过好几次坑。有一次是对方设备的波特率实际上是115200,但它的时钟源精度不够,实际波特率偏差了2%,导致偶尔出现帧错误。这种问题用逻辑分析仪抓波形最直观,能看到停止位采样点偏移。
4.3 中断风暴与系统卡死
中断风暴是串口驱动里比较严重的问题,表现是系统响应变慢,top里看到irq/xxx-msm_serial占用大量CPU。原因通常是中断清除逻辑有问题,导致同一个中断被反复触发。
我在一个项目里遇到过TX中断风暴,原因是发送完成后没有正确清除TX中断标志。高通UART的TX中断是电平触发的,只要FIFO低于水位线就会一直触发。如果驱动没有在中断处理里往FIFO写数据或者关闭TX中断,就会陷入死循环。
解决办法是在msm_stop_tx里正确关闭TX中断,在msm_start_tx里根据FIFO状态决定是否使能TX中断。另外,中断处理函数里要确保每次都能推进状态,不能有提前返回而没清中断的路径。
实操心得:调试中断问题时,可以在中断处理函数入口和出口各加一个计数器,通过
cat /proc/interrupts观察中断次数的增长速率。如果每秒增长几万次,基本可以确定是中断风暴。
4.4 低功耗模式下的串口唤醒问题
高通平台对功耗管理很严格,系统进入suspend后,串口默认会关闭时钟。如果外部设备在系统休眠时发送数据,串口无法唤醒系统,数据就丢了。要支持串口唤醒,需要在设备树里配置wakeup-source属性,并且在驱动里正确配置中断唤醒。
uart2: serial@78b0000 { ... wakeup-source; interrupt-parent = <&tlmm>; interrupts-extended = <&tlmm 20 0>; };驱动侧需要在msm_shutdown或suspend回调里把RX引脚配置成中断模式,收到起始位时触发唤醒。这个功能调试起来比较麻烦,因为涉及电源管理子系统和中断子系统的交互。我建议先用echo mem > /sys/power/state手动触发休眠,然后用外部设备发数据,看系统能不能被唤醒。如果不能,检查/sys/kernel/debug/wakeup_sources里串口有没有注册成唤醒源。
5. 驱动调试手段与性能优化建议
5.1 常用调试工具与技巧
调试msm_serial.c离不开几个工具。首先是dmesg,驱动里的dev_dbg和dev_err都会输出到这里。但默认情况下dev_dbg是不打印的,需要打开动态调试:
echo "file msm_serial.c +p" > /sys/kernel/debug/dynamic_debug/control这样就能看到驱动内部的详细日志了。其次是debugfs里的串口状态信息,高通串口驱动通常会在/sys/kernel/debug/msm_serial/下创建一些调试节点,可以查看FIFO状态、DMA计数、中断统计等。
逻辑分析仪和示波器是硬件层面的利器。串口通信问题最终都要落到波形上,波特率、数据位、停止位、电平极性,这些用示波器一看便知。我习惯用逻辑分析仪抓一段完整的数据帧,然后和驱动里打印的收发数据做对比,能快速定位是驱动问题还是硬件问题。
5.2 性能优化:从轮询到DMA的取舍
低波特率下,用中断模式就够了,实现简单,CPU占用也不高。但波特率超过460800之后,中断频率会显著上升。以115200波特率为例,每字节约87微秒,如果连续收发,每秒中断上万次。到了921600,每秒中断近十万次,CPU大量时间花在中断上下文里。
DMA模式可以把CPU从频繁的中断中解放出来。配置好DMA后,接收方向DMA会自动把FIFO数据搬到内存,攒够一定数量再触发一次中断;发送方向DMA从内存搬数据到FIFO,搬完触发完成中断。这样中断频率可以降低一到两个数量级。
但DMA不是银弹。小数据量、低波特率场景下,DMA的配置开销可能比中断还大。而且DMA调试复杂,出问题时排查难度高。我的建议是:波特率低于230400用中断模式,高于这个值再考虑DMA。如果项目对功耗敏感,DMA模式也更省电,因为CPU可以更长时间待在idle状态。
5.3 多串口并发场景的注意事项
高通平台通常有多个UART控制器,比如BLSP1的UART0到UART5。多个串口同时工作时,要注意中断号和DMA通道的分配不能冲突。设备树里每个串口节点的interrupts属性必须唯一,DMA通道也要错开。
我在一个项目里同时用了三个串口,其中两个配了相同的DMA通道,结果只有一个能正常工作,另一个要么收不到数据要么发送卡死。后来查DMA控制器的通道分配表才发现冲突了。高通平台的DMA通道是有限资源,设备树里配置的dmas属性必须和硬件手册里的通道映射一致。
另外,多串口并发时,中断处理函数的执行时间会累加。如果每个串口的中断处理都很耗时,系统整体响应会变慢。优化方法是把中断处理里耗时的部分放到tasklet或工作队列里,中断上下文只做最紧急的数据搬运。
6. 从驱动到应用:完整链路的联调经验
6.1 应用层打开串口的正确姿势
驱动调通之后,应用层怎么用也很关键。Android上打开串口通常用open("/dev/ttyHSL0", O_RDWR | O_NOCTTY),然后通过termios结构体设置波特率、数据位、停止位、校验位。这里有个常见错误:打开串口后没有用tcgetattr获取当前配置就直接tcsetattr,导致一些默认配置被覆盖。
int fd = open("/dev/ttyHSL0", O_RDWR | O_NOCTTY); struct termios tio; tcgetattr(fd, &tio); cfsetispeed(&tio, B115200); cfsetospeed(&tio, B115200); tio.c_cflag |= CS8 | CLOCAL | CREAD; tio.c_cflag &= ~PARENB; tio.c_cflag &= ~CSTOPB; tcsetattr(fd, TCSANOW, &tio);CLOCAL和CREAD必须置位,否则串口可能不接收数据。TCSANOW表示立即生效,也可以用TCSADRAIN等发送缓冲区空了再生效。这些细节在驱动层面看不到,但应用层配错了,表现和驱动问题一模一样。
6.2 权限管理与SELinux策略
Android对设备节点的权限管理很严格。默认情况下/dev/ttyHSL0的权限是root:root 600,普通应用打不开。要让应用能访问,需要在ueventd.rc里添加规则:
/dev/ttyHSL0 0666 system system或者更精细地指定用户组。另外SELinux策略也要放行,否则即使权限对了,SELinux还是会拒绝访问。需要在file_contexts里给设备节点打上正确的标签,然后在te文件里添加allow规则。
我遇到过权限和SELinux都配了但应用还是打不开的情况,最后发现是ueventd.rc的修改没有生效,因为设备节点是驱动创建时自动生成的,ueventd规则要在驱动probe之前就加载。解决办法是把规则放到正确的rc文件里,或者用chmod在init脚本里手动改权限。
6.3 数据完整性校验与流控
串口通信没有TCP那样的重传机制,数据丢了就是丢了。在对可靠性要求高的场景下,应用层需要自己做校验和重传。常见的做法是在数据帧里加CRC校验,接收方校验失败就请求重发。
流控也是保证数据完整性的重要手段。高通UART支持硬件流控(RTS/CTS),在设备树里配置rts-gpios和cts-gpios后,驱动会自动处理流控信号。但硬件流控需要对方设备也支持,并且连接线要接对。如果只有TX和RX两根线,就只能用软件流控(XON/XOFF),在termios里设置IXON | IXOFF。
我在一个项目里因为没开流控,高速传输时对方设备处理不过来,导致数据丢失。后来加上硬件流控,问题立刻解决。所以如果你的应用场景数据量大、速率高,强烈建议把流控配上。
7. 版本差异与移植注意事项
7.1 不同内核版本的驱动差异
msm_serial.c在不同内核版本之间有差异。早期版本(3.10、3.18)的代码结构比较原始,DMA支持也不完善。到了4.9、4.14,驱动重构过,引入了dmaengine框架,DMA配置方式变化很大。5.4之后又进一步清理了时钟管理和电源管理相关的代码。
移植驱动时,不能直接把旧版本的msm_serial.c拷到新内核里用。串口核心层的API变了,比如uart_insert_char的参数在某个版本调整过,tty_flip_buffer_push的调用时机也有变化。我的做法是先看新内核里有没有现成的高通串口驱动,如果有就基于它改;如果没有,再参考旧版本移植,但要把API调用逐个核对。
7.2 高通平台不同芯片的UART差异
高通骁龙系列芯片的UART控制器基本兼容,但细节上有差异。比如某些芯片的UART FIFO深度是64字节,另一些是128字节;某些芯片支持DMA,另一些只支持中断模式;时钟源的频率和分频方式也可能不同。
这些差异通常通过compatible字符串来区分。msm_serial.c里会为不同芯片定义不同的msm_uart_config结构,里面包含FIFO深度、是否支持DMA、寄存器偏移等参数。移植到新芯片时,第一件事就是确认这些参数和硬件手册一致。
我移植过一个串口驱动到某款新芯片上,一开始通信正常但高波特率下丢包。查了很久才发现新芯片的FIFO深度比旧芯片小,驱动里用的还是旧的水位配置,导致FIFO溢出。把水位参数改小之后问题就解决了。所以硬件手册里的FIFO深度、DMA突发长度这些参数,一定要逐个核对。
7.3 从调试串口到应用串口的切换
高通平台默认会把某个UART配置成调试串口,输出内核日志。这个串口在设备树里的节点通常带有stdout-path属性。如果项目需要把这个串口改成普通应用串口,需要做几件事:
- 去掉设备树里的
stdout-path或linux,stdout-path属性 - 修改bootargs,去掉
console=ttyHSL0之类的参数 - 确认驱动里没有把这个串口注册成console
如果只是去掉bootargs但没改设备树,内核可能还是会占用这个串口。反过来,如果保留console功能又想同时给应用用,可以通过console=和earlycon参数控制,但应用层打开同一个设备节点可能会有冲突。最干净的做法还是分开,调试串口和应用串口各用一个UART控制器。
8. 个人实操体会与后续扩展方向
调msm_serial.c这些年,最大的体会是:串口问题看起来简单,但涉及硬件、时钟、引脚、中断、DMA、电源管理、TTY子系统等多个层面,任何一个环节出问题都会表现为“串口不通”。排查时一定要有层次感,从设备节点是否存在,到时钟是否使能,再到引脚复用、中断触发、数据收发,逐层往下查,不要一上来就盯着驱动源码看。
另一个体会是,高通平台的文档虽然多,但很多细节藏在代码里。比如某个寄存器的位定义,文档里可能只写了功能描述,具体哪个位对应哪个中断源,还是要看驱动里的宏定义和注释。我习惯把关键寄存器的位定义整理成表格,调试时对照着看,效率会高很多。
后续如果要做扩展,可以考虑几个方向:一是把串口驱动和earlycon结合,实现内核启动早期的串口输出,这对调试启动阶段的问题很有帮助;二是研究高通平台的串口唤醒机制,在低功耗场景下实现可靠的唤醒;三是把串口驱动和tty子系统的line discipline结合,实现一些自定义的协议处理,减少应用层的负担。
最后分享一个小技巧:如果怀疑是驱动问题但不确定具体位置,可以在msm_serial.c的关键函数入口加printk,然后通过dmesg -w实时观察。虽然printk会影响性能,但调试阶段能快速定位问题范围。定位到具体函数后,再用dev_dbg和动态调试做精细分析。这个笨办法在紧急问题排查时往往最有效。