资讯中心

Expect交互式自动化脚本实战:SSH、串口与芯片验证全攻略

📅 2026/9/29 15:38:58
Expect交互式自动化脚本实战:SSH、串口与芯片验证全攻略
1. 交互式命令行为什么让自动化工程师头疼做芯片验证或者搞自动化运维的同学十有八九都遇到过这样一个场景你写好了几百行测试脚本逻辑清晰、注释完整结果一跑起来卡在某个交互式命令上——要么是SD卡量产工具弹出一个Press any key to continue要么是登录服务器时提示password:要么是测试机上的uboot等你输入命令却没等到。程序停在那儿半个字也不说像个闹脾气的机器人你只能手动敲一下回车然后眼睁睁看着后面几步继续跑。一次两次还行当你需要同时操控十几台设备的时候手动交互就是整个自动化链路里最大的瓶颈。Expect 就是专门解决这个问题的工具。它诞生于1990年代初期作者是Don Libes本意是给Tcl语言写一个交互式自动化扩展。它的核心思想很简单启动一个程序然后像人一样盯着它的输出一旦匹配到特定字符串就自动替人把输入发过去。这个过程不需要人的参与也不需要修改被操控程序的任何代码完全从外部“模拟”一个人在工作。这套思路在芯片验证领域尤其有用。芯片验证工程师日常要面对的交互式工具太多了串口终端的登录验证、uboot环境变量设置、DDR训练脚本的交互问答、ATE自动测试设备的指令下发、以及远程服务器上各种测试框架的启动流程。这些工具的底层往往依赖一串交互提示符而交互相对于普通命令行脚本来说是最难自动化的部分。Expect 恰好能在这个位置上填补空白。这篇笔记不是照抄man手册而是我实际在芯片测试项目和自动化环境搭建过程中把Expect从简单应用到复杂场景扎扎实实过了一遍之后整理出来的完整路线。从四个核心命令的原理讲起到SSH自动化和芯片验证中的真实用例再到脚本设计的工程化模式和调试技巧。无论你是刚接触脚本的小白还是已经写过不少shell但被交互式工具卡住的老手这篇文章都能让你少走不少弯路。2. 我能写出什么程度的脚本——在动手之前先搞懂Expect的能力边界在正式开始写代码之前我建议大家先把Expect的能力边界搞清楚。很多人对它有两个误区一个是觉得Expect无所不能什么自动化都能做另一个是觉得Expect没必要学用shell重定向或者管道就能搞定。这两种想法都不对。Expect的定位非常精准它只解决“交互式程序”的自动化问题。所谓交互式程序就是那个程序运行起来后会主动等待键盘输入在收到输入之前不继续往下执行的程序。典型的例子就是telnet、ftp、su切换用户、还有各种带密码提示的工具。这类程序有几个共同特点程序运行过程中会向stdout输出提示符字符串程序会阻塞等待stdin的输入输入是否正确直接决定程序下一步走向程序可能有超时机制超过一定时间不输入就断开。而shell里的管道echo password | ssh ...虽然也能往stdin塞数据但它有个致命缺陷管道不会等待程序的输出也不会根据不同的输出内容来决定下一步做什么。这就等于蒙着眼睛开飞机一口气把所有输入全灌进去。碰到那种先问密码、再问y/n确认、最后问保存路径的交互流程管道方案直接废掉。Expect给出的解法是“事件驱动”程序输出什么脚本就响应什么输出变了响应也跟着变。这正是它与普通shell脚本的本质区别。还要提前说清楚一个边界Expect管不了图形界面的自动化也别指望用它去控制浏览器。GUI自动化有专门的工具链比如Selenium、Appium跟Expect完全是两码事。Expect的战场是终端、命令行、串口、以及所有基于文本协议交互的老牌工具。搞清楚了这条边界你就能判断什么场景该上Expect什么场景该换工具。3. 环境准备和第一个能跑起来的脚本3.1 安装不同系统下的最小操作步骤Ubuntu/Debian系的安装很简单一条命令sudo apt-get update sudo apt-get install -y expectCentOS/RHEL系的机器用sudo yum install -y expectmacOS上如果你装了Homebrew直接brew install expect也行。Windows环境稍微麻烦一点但Cygwin或者WSL里面其实都能跑。我个人的建议是如果你在Windows上做芯片验证开发优先用WSL跑Expect脚本因为WSL环境跟Linux环境几乎零差别脚本拿过去就能用省掉一堆转义和换行符的麻烦。验证安装是否成功执行expect -v正常会输出类似expect version 5.45.4的内容。注意Expect版本5.45.x到现在还是主流别在新系统上期待6.x的大版本官方长年保持稳定这本身就是成熟工具的标志。3.2 第一个脚本自动应答一个简单问答我们不用太复杂的场景先写一个最简单的交互问答自动应答。假设有一个脚本ask.sh内容如下#!/bin/bash # 模拟一个交互式程序 echo 请输入你的名字 read name echo 你好$name欢迎使用本工具。正常情况下你需要手动输名字。现在我们用Expect脚本来自动应答#!/usr/bin/expect -f # 设置超时时间单位秒 set timeout 10 # 启动被控程序 spawn ./ask.sh # 等待屏幕输出匹配请输入 expect 请输入 # 自动输入名字并回车 send chip_verify_engineer\r # 让脚本把控制权交还给屏幕能看到完整输出 expect eof这个脚本里spawn负责启动进程expect负责等待输出send负责发送输入expect eof负责等程序结束。跑一下看看效果$ expect answer.exp spawn ./ask.sh 请输入你的名字 你好chip_verify_engineer欢迎使用本工具。看到没有全程不需要手动操作程序就走完了。虽然这只是个玩具例子但它已经包含了Expect脚本最核心的全部骨架。后面的一切都是在这个骨架上加细节、加健壮性、加工程化能力。3.3 文件头与执行权限——容易被忽略但又必须规范的细节写Expect脚本我强烈建议统一加这三行#!/usr/bin/expect -f # 描述脚本用途 # 作者yourname / 日期第一行#!/usr/bin/expect -f中的-f参数告诉系统把后面跟的文件当作Expect脚本执行而不要把文件名当作命令行参数传给expect命令本身。很多人写脚本时省略了这个-f在部分环境下会碰到诡异的行为因为expect会把脚本文件名当成要执行的命令的参数导致直接报错或者行为异常。强烈建议写成-f省心。执行时先加权限chmod x answer.exp ./answer.exp或用expect answer.exp方式执行两者等价。不过工程上推荐第一种因为当你需要把脚本路径传给其他工具、或者用crontab定时跑的时候可执行权限会省掉很多兼容问题。4. spawn、expect、send、expect eof——四个核心命令的底层逻辑和实际用法4.1 spawn启动一个被监控的进程而不是普通的forkspawn的底层行为跟shell里直接执行命令完全不同。它不只是fork一个子进程还干了两件重要的事一是把子进程的stdout改成Expect内部的一个伪终端PTY二是让Expect可以随时监控这个PTY上的输出流。伪终端是Unix系统上模拟真实终端行为的机制程序在PTY里运行会以为自己正面对一个用户从而输出交互式的提示符、处理回显、响应控制字符等等。这个细节极其重要。很多交互式程序比如SSH登录时的密码提示会检查自己所在的会话是不是一个真正的终端如果不是就直接拒绝交互甚至报stdin: is not a tty的错误。Shell管道方案没法通过这个检测而Expect因为用了PTY能完美骗过这一层检测让SSH这类程序以为用户真的在终端里敲键盘。这就是Expect能做到而普通管道做不到的核心原因。spawn的语法很简单spawn command [arg1] [arg2] ...比如spawn ssh root192.168.1.100 spawn ./flash_tool --config ddr4.ini spawn telnet 192.168.1.50 8080spawn执行完后不会等进程结束而是立即返回让Expect脚本继续往下执行expect语句去匹配输出。这一点要注意如果你紧接着写的expect要匹配的内容比较长但程序输出很快可能匹配过程会遇到超时问题。解决办法就是调大set timeout或者用正则匹配里更灵活的方式。4.2 expect核心匹配机制不是简单的字符串查找expect命令做的事情是从PTY输出缓冲区中读数据逐一跟给定模式匹配匹配成功就执行对应的动作匹配失败则一直等到超时。这里的“模式”有两种形态搞懂它们的区别是写脚本的关键精确字符串匹配。比如expect password:这是最简单也最常用的写法。注意这里匹配的是输出流中的子串不是逐字符严格相等。只要屏幕输出的内容包含password:这个子串就算匹配成功。所以Please enter password:里包含了password:也能命中。正则表达式匹配。在Expect中使用正则时需要写成expect -re {([0-9]) errors found}花括号里是Tcl风格的正则表达式。用-re进去之后匹配能力成倍扩展。比如你想匹配任意的IP地址直接写expect -re {(\d\.){3}\d}匹配成功后$expect_out(1,string)存放第一个捕获组的内容$expect_out(buffer)存放的是本次匹配触发前缓冲区里的全部内容。这在后面写日志、提取关键信息时很重要。expect还可以一次列出多个模式相当于同时监听多个可能性expect { success { send_user 测试通过\n } failed { send_user 测试失败\n; exit 1 } timeout { send_user 等待超时\n; exit 2 } }这种写法特别适合芯片验证里需要判断多分支结果的场景。实际码代码时百分之八十的Expect脚本都是在调整这个多模式分支的结构。4.3 send模拟键盘输入注意结尾的回车send负责把字符串写到PTY也就是伪终端的输入流上。一个经典新手错误是忘了加回车符导致程序收到了字符串但不知道你已经输完了。如果提示符要求你输入完按回车那字符串末尾一定要加\rsend admin\r这里有个细节为什么是\r而不是\n因为在真实终端里回车键发送的是回车符CR\r而换行符LF\n是程序输出时才用的。如果你发\n部分程序也能正确解析但最稳妥、最“真人”的做法是发\r。我之前就碰到过一个加密工具对换行符特别敏感\n会让它把密码跟换行一起当作密码内容导致认证失败改成\r后立刻正常。这种小坑文档里往往不会写只有踩过才知道。如果只是想向终端输出调试信息而不是模拟键盘输入应该用send_usersend_user 开始执行第2轮测试...\nsend跟send_user的区别一定要分清前者是发给子进程的输入流后者是写给用户也就是屏幕的。搞混了就会出现“屏幕上看到一堆乱码程序那边啥也没收到”的诡异现象。4.4 expect eof把控制权交给屏幕还是优雅收尾expect eof的作用是等待子进程退出并让Expect脚本把PTY缓冲区的后续输出“倒”到屏幕上保证你还能看到程序最后的完整打印。很多初学者省略这句话结果脚本跑完屏幕上什么都没有以为自己写错了。实际上不是写错了而是Expect把进程的输出缓冲跟脚本自己的输出隔离了你不主动消费那个缓冲区就看不到任何东西。以下三种收尾方式按使用频率排序# 1. 等程序自然结束 expect eof # 2. 把剩余控制权还给用户交互式结束 interact # 3. 不等了强制结束程序 closeinteract在自动化阶段结束后特别好用——比如你用Expect完成了SSH的密码登录登录成功后希望把终端交还给你让人继续手工操作就是用interact。这在实际工作中的使用频率相当高属于“半自动化”场景的标准解法。5. 最经典的实战案例用Expect把SSH登录做成全自动化5.1 为什么SSH登录是Expect的“必修课”芯片验证环境里最常见的操作就是登录各类服务器、跳板机、测试平台然后跑脚本、看日志、取数据。人的手工操作每来一次就输入一次密码反复几次之后就开始烦躁而自动化任务则需要脚本自己登录。SSH恰好又是交互式程序中的典型代表它会检查是否为真实终端、会提示输入密码、还会因为密码错或者host key变化给出不同报错。因此SSH自动化就成了Expect绕不开的必修课。只要用Expect把SSH登录做通SSH登录背后的整个原理链路——PTY、提示符匹配、超时处理、分支判断——就全打通了。后面所有的工具自动化都只是在换不同的提示符、不同的命令而已。5.2 一个完整的自动登录执行命令脚本直接上一个我实际在用、做了基本健壮性处理的模板#!/usr/bin/expect -f set timeout 15 set ip [lindex $argv 0] set user [lindex $argv 1] set pass [lindex $argv 2] set cmd [lindex $argv 3] log_user 1 spawn ssh -o StrictHostKeyCheckingno $user$ip $cmd expect { -re (?i)password: { send $pass\r exp_continue } -re (?i)yes/no { send yes\r exp_continue } Permission denied { send_user 密码错误登录失败\n exit 1 } timeout { send_user 连接超时\n exit 2 } eof { send_user 连接正常退出\n exit 0 } }几个细节说明一下[lindex $argv 0]是Tcl里取命令行参数的方式。$argv是参数列表[lindex ...]取第几个。如果你有Python基础可以类比sys.argv[0]。-o StrictHostKeyCheckingno用来跳过首次连接的主机指纹确认这是自动化场景的标准操作。如果你觉得这样不够安全也可以第一次先手动确认一次后续再自动化。(?i)是正则里的忽略大小写修饰符因为不同系统提示符大小写不一定加上它更稳。exp_continue的意思是这个分支匹配成功并执行动作后不退出匹配循环继续等待下一轮匹配。密码输入完以后程序可能还有后续输出你需要继续监听是否存在新的提示符或者其他结果。5.3 传参设计的习惯用法从“脚本里写死”到“命令行传参”初学者最容易犯的错就是把IP、用户名、密码全写死在脚本里set ip 192.168.1.10 set user root set pass 123456这么干开发调试阶段没问题但一旦脚本要多台机器复用或者被自动化框架动态调用写死就是灾难。更好的习惯是从命令行接收参数./ssh_auto.exp root 192.168.1.10 uname -a Pssw0rd脚本内部用$argv取得参数列表后再通过lindex分配变量。我在实际项目里还会做一个参数个数校验if {[llength $argv] ! 4} { send_user 用法$argv0 用户 IP 命令 密码\n exit 1 }$argv0在Tcl里是当前脚本文件名。有了这个校验别人拿到你的脚本就不会傻傻地一次性传错参数还不知道问题出在哪。5.4 SSH连不上的常见原因排查思路自动登录出现异常时不要急着调Expect代码先按这个顺序排查手动试一下能否登录。用同样的IP、用户、密码手动执行一次如果手动都登不上那就是网络、账号、密码本身的问题比如IP不可达、密码过期、远程主机拒绝root登录这些都不是Expect的锅。确认提示符文本。不同系统的SSH密码提示可能是Password:、password for user:或者中文系统里的备用提示。用-re 忽略大小写能覆盖大多数情况。检查超时设置。默认timeout是10秒如果你的网络比较慢很可能会在密码提示出现之前就超时了。调到20-30秒一般就稳了。把log_user 0打开以静默方式跑一遍。log_user 1是回显屏幕输出log_user 0是关闭回显。调试时暂时用log_user 1但同时也会看到大量交互过程输出分不清重点的话在关键步骤加gets或者send_user打印中间状态。核心的经验是线上环境出了问题先解决“程序本身能否登录”的问题再解决“登录脚本匹配是否准确”的问题不要混在一起排查。6. Expect在芯片验证中的真实落地场景6.1 场景一串口交互式登录与命令验证芯片验证里最基础的交互式场景就是串口。芯片上电后串口终端会出来一个登录提示符通常是login:或者直接进入shell提示符。你要做的第一件事可能就是登录然后控制板子执行命令、读取日志。常规做法是minicom或者picocom手动登录但测试要反复跑几百次每次都是手工登录效率慢且容易遗漏。用Expect可以像这样#!/usr/bin/expect -f set timeout 5 set serial_port /dev/ttyUSB0 set baud_rate 115200 # 打开串口spawn一个串口终端程序作为被控进程 spawn picocom -b $baud_rate $serial_port expect { -re login: { send root\r exp_continue } -re Password: { send 123456\r exp_continue } -re root.*# { send_user 登录成功\n } timeout { send_user 登录超时请检查串口设备/波特率\n exit 1 } } # 发送测试命令比如读取SoC温度 send cat /sys/class/thermal/thermal_zone0/temp\r expect -re {(\d)} if {$expect_out(1,string) 80000} { send_user 温度异常当前温度 $expect_out(1,string)\n } else { send_user 温度正常 $expect_out(1,string)\n } send exit\r expect eof这个脚本干的实际事情就是通过串口登录板卡、读取温度传感器、做逻辑判断、退出。对于验证工程师来说这个模式可以无限复制——无论你要读的是内核日志、跑memtester、改寄存器值还是配置网络核心骨架都是一样的登录 - 发命令 - 匹配输出 - 判断结果。6.2 场景二uboot交互与固件烧录自动化芯片验证中另一类高频交互场景是ubootU-Boot引导程序环境。芯片进入uboot后你会看到一个Hit any key to stop autoboot的倒计时提示如果不在倒计时结束前按任意键板子就会正常启动内核但如果你想停在内核启动前去改uboot环境变量、加载固件、烧写flash就必须在这个窗口期内干预。用Expect做自动化分秒级干预非常合适#!/usr/bin/expect -f set timeout 10 spawn console_connect # 假设这是连接调试板的命令 # 等待倒计时提示快速中断autoboot expect { -re Hit any key to stop autoboot { send \r exp_continue } -re { send_user 已进入uboot命令行\n } timeout { send_user 板子可能已经启动到内核或串口连接异常\n exit 1 } } # 设置环境变量并保存 send setenv bootdelay 3\r expect send setenv serverip 192.168.1.100\r expect send setenv ipaddr 192.168.1.50\r expect send saveenv\r expect # 然后可以做固件烧写、tftp加载等操作 send tftp 82000000 uImage\r expect send tftp 83000000 rootfs.ext4\r expect send_user uboot环境配置与固件加载自动化完成\n注意这里面的expect 是uboot的提示符每执行完一条命令提示符变化表示命令已经执行结束。这里要特别强调的是每条命令发完之后都必须等待提示符重新出现再发下一条命令。如果不停顿地连续senduboot那边还没处理完上一条命令你下一条指令就“挤”进来了轻则命令丢失重则状态错乱。这个“命令-等待-反馈-再命令”的循环就是所有交互式自动化脚本的节奏感所在。6.3 场景三批量测试中日志实时抓取和时间戳记录芯片验证的很多任务是长时间跑的那种比如老化测试、稳定性测试、压力测试。这种场景下你不仅要自动操控设备还要把过程中的日志完整保留下来最好带上时间戳。Expect实现这个非常容易。一个典型用法是给命令行工具包一层记录器#!/usr/bin/expect -f set timeout 3600 set logfile [open /tmp/chip_test_$(date %Y%m%d_%H%M%S).log w] log_user 0 spawn ./stress_test --loop 10000 while {1} { expect { -re (.*)\n { set line $expect_out(1,string) puts $logfile [clock format [clock seconds] -format %Y-%m-%d %H:%M:%S] $line exp_continue } eof { send_user 测试程序已结束\n break } timeout { send_user 测试无输出超时检查是否卡死\n break } } } close $logfile这个脚本实际上是把echo到的每一行输出都加了时间戳写入日志文件同时在屏幕上实时打印便于观察测试进度。我在实际的芯片老化测试里用类似方案连续跑过几天日志完整、时间线清晰后面分析哪个阶段崩了直接翻日志就能定位比漫无目的地“复现一次”高效得多。6.4 场景四DDR/PMIC调试中的寄存器读写自动化芯片验证后期DDR和PMIC调试经常涉及寄存器读写。如果你用的是串口命令行方式那交互流程通常是登录shell - 切换寄存器调试工具 - 写地址 - 读值 - 判断是否符合期望值。这一系列步骤如果全部手工做一次调测几十个寄存器得折腾半天。用Expect封装一个寄存器读写器就可以像这样#!/usr/bin/expect -f proc reg_write {bus addr val} { send regutil -w $bus $addr $val\r expect OK } proc reg_read {bus addr} { send regutil -r $bus $addr\r expect -re {value0x([0-9a-fA-F])} return $expect_out(1,string) } spawn console_connect expect login: send root\r expect Password: send 123456\r expect # reg_write 0x40 0x1c 0x55aa set rd [reg_read 0x40 0x1c] send_user 回读结果0x$rd\n if {$rd 55aa} { send_user 寄存器读写测试通过\n } else { send_user 寄存器读写失败\n }这里引入了proc关键词这是Tcl语言里定义函数的方式Expect完全继承了Tcl的语言能力。上面的脚本里我把reg_write和reg_read定义成了两个函数这样重复调用的代码被大大压缩。当你写Expect脚本时发现某个操作重复出现三次以上就该把它抽成一个proc函数了。这个习惯会让脚本变得极其干净易于维护。7. 超时、日志、错误重试——把Expect脚本从“能用”改成“好用”7.1 timeout这数值到底设多少才合理很多Expect脚本出问题不是逻辑错了而是timeout设置不合理。timeout默认是10秒但在不同场景下需要灵活调整场景建议timeout理由本地快速命令ls、echo5-10秒命令瞬间返回不需要久等串口登录5-10秒板子上电后提示符出现一般在几秒内SSH登录15-30秒网络延迟、DNS解析、加密协商都可能变慢uboot flash烧写60-300秒大固件烧写耗时可能以分钟计老化测试/长期压力测试3600秒以上长时间无输出可能会误判为超时timeout设为 -1 表示永不超时。拿来做长期跑批任务时可能会用到但要小心配合expect eof的兜底逻辑才安全。7.2 日志记录的工程化log_file与自定义日志轮转Expect自带log_file命令可以很方便地把所有输出写入文件log_file /tmp/expect_debug.log spawn ./test_program expect eof调试阶段我会习惯性打开log_file跑完以后直接翻日志看哪些步骤匹配不上了。生产环境下为了避免单文件无限膨胀可以在脚本里手动控制日志文件切分set count 0 while {1} { incr count log_file /tmp/run_${count}.log log_user 0 spawn ./test_program expect eof if {$count 100} { break } }这个写法按轮次生成独立日志文件排查问题的时候非常清晰不需要在一个巨大的日志文件里人肉翻找。7.3 失败自动重试的安全姿势自动化执行时总会有不稳定因素——网络抖动、设备忙、偶然超时。工程上需要给关键步骤加一个重试机制但又要避免无脑重试导致卡死。我常用的重试模式proc retry_command {cmd_pattern retry_times} { for {set i 0} {$i $retry_times} {incr i} { send restart_test\r expect { PASS { return pass } FAIL { send_user 第 [expr {$i1}] 次测试FAIL\n } timeout { send_user 第 [expr {$i1}] 次超时\n } } } return fail }调用set result [retry_command PASS 3] if {$result fail} { send_user 三连测全部失败停止后续流程\n exit 1 }注意这里重试不是简单地把同一条命令发出去而是要先把环境恢复到一个已知状态比如先stop再start再去执行测试。盲目重复同一条命令如果第一次就进了错误状态那第2次第3次大概率还是同样的错。重试的本质是“从干净状态重新开始”而不是“再撞一次南墙”。7.4 全局流程控制多台设备并发验证芯片验证经常要同时跑多块板子如果用一堆终端窗口手工操作人在其中忙得晕头转向。Expect脚本是纯命令行程序天然适合并行化。最简单的并发方式是用shell在后台同时启动多个Expect进程#!/bin/bash for board in 192.168.1.11 192.168.1.12 192.168.1.13; do ./board_test.exp root $board memtest --stress /tmp/result_${board}.log 21 done wait echo 所有板子测试结束每个Expect脚本负责一块板子的全流程后台并发执行最后统一汇总结果。这样做最大的好处是单脚本的复杂度不增加并发能力是shell直接给的。我在一个验证项目里用这种方式同时管理过20多块板子效果非常稳定。如果你希望更精细地控制并发比如“同时最多跑5个”“每个结束后自动领下一个任务”那可能需要引入更完整的工作流工具比如Jenkins的并发构建或自研调度脚本。但对大多数场景来说shell Expect的组合已经足够。8. 复杂场景处理interact、子进程嵌套和正则提取8.1 interact自动化与手动操作的交接棒一个典型场景自动化登录服务器后想自己做些临时操作。前面脚本跑到最后全是expect eof等程序自己结束但有时候你不想让程序结束你想接管。这就轮到interact上场spawn ssh root192.168.1.100 expect password: send mypassword\r expect # send_user 自动化登录完成下面交给手动操作\n interactinteract会把终端的控制权完全交给用户Expect终止对PTY的监控此时你就像直接SSH登录上去一样所有按键都发给远程进程。如果后续还想再回到自动化可以用interact加返回触发器比如按Ctrl]回到Expect里继续操作。这在多级跳板登录、需要临时确认硬件状态的场景下特别好用。8.2 嵌套Expect控制一个再控制一个你可能碰到过这样一个流程先登录跳板机再从跳板机SSH到内网服务器内网服务器上再登录某个工具的控制台。这么深的嵌套单个spawn显然不够。Expect里有两个思路第一个是反复spawn前一个进程结束后再启动下一个。比如spawn ssh userjump_host expect password: send jump_pass\r expect # # 在跳板机上敲一条命令登录内网服务器 send ssh rootserver1\r expect password: send server_pass\r expect # send cd /test ./testapp\r interact这个写法本质上是“通过父进程的会话继续启动子进程的交互”由于Expect不停地在监听父进程的输出所以SSH到server1后的密码提示也会被捕获到。只要提示符匹配准确这个链条可以一直延伸下去。第二个思路是用spawn开启一个终端模拟器比如xterm或者屏幕复用工具screen在里面做完整交互Expect只负责最外层的启动。这个方法比较重不如第一种思路轻量但遇到特别复杂的带颜色的交互界面时会更可靠。8.3 正则提取从命令输出里拿到关键数值Expect里最强大的地方就是能直接用正则从程序的输出中提取数据。配合Tcl的变量和流程控制能做的事情立马指数级上升。一个典型的例子板卡跑完性能测试stdout里会输出吞吐量数字你要把这个数字自动提取出来跟阈值比较确定板卡是否合格。看这段代码spawn ./throughput_test --duration 60 expect -re {Throughput:\s([0-9]) Mbps} set tp $expect_out(1,string) send_user 吞吐量$tp Mbps\n if {$tp 1000} { send_user PASS达到千兆要求\n } else { send_user FAIL吞吐量不达标\n exit 1 }这里的正则Throughput:\s([0-9]) Mbps匹配“Throughput: 1234 Mbps”这种输出格式括号里的([0-9])把数值部分捕获出来。$expect_out(1,string)存的正是第一个捕获组对应的字符串。这样做的好处是你不需要竞速手抄数字脚本里自动完成了数据和判断并且直接拿到一个退出码供上层框架解析。一个容易忽略的点-re模式下正则用的是POSIX风格还是Tcl风格Expect沿用Tcl的regexp语法它跟Linux上grep用的POSIX ERE扩展正则表达式非常接近但有些语法细节不一样。比如Tcl的正则用{...}而不是/.../做定界符且反斜杠需要小心处理。如果你之前写sed/awk正则习惯了刚切过来容易在转义上踩坑建议先写最简模式逐步加复杂度。9. 你跟“老手”之间的差距——调试技巧和Easter Egg级细节9.1 expect的debug模式一眼看穿匹配哪里出了问题脚本跑出问题后最有效的调试方式是用Expect自带的调试模式expect -d script.exp-d会打开诊断输出屏幕上会详细显示每次匹配扫描到哪些缓冲区内容模式匹配成功的具体位置发送的字符串内容等待的超时时刻。我第一次看到这个输出的时候简直觉得这工具太贴心了。有了它你不需要瞎猜“是不是spawn错了”“是不是正则写错了”一眼就能看出Expect从PTY那里收到底看到了什么文本以及你的模式跟这个文本之间差在哪一个字符。很多匹配失败的根源不是因为代码逻辑错而是因为提示符字符串跟你想的不完全一样。比如用户名登录前后可能有ANSI颜色控制码或者提示符前面带了一串转义序列屏幕上看不到但缓冲区里它们真实存在。调试模式下这些隐藏字符都会通过转义后的形式显示出来你照着实际文本改模式就行了。9.2 常见报错信息全解析我整理了一份高频报错的检查和解决方案每次脚本出错先翻这张表能省不少时间报错/现象最可能原因解决办法spawn: command not found被控程序不在PATH里使用绝对路径如spawn /usr/local/bin/xxxexpect: invalid command name命令拼写错或Expect版本过老检查拼写更新ExpectExpect Script timed outtimeout设短了调大timeout或设set timeout -1永不超时脚本执行完没有任何输出少了expect eof或log_user被设成0检查收尾语句和log_user设置程序收到了输入但没反应send末尾漏了\r检查send字符串里的回车符密码总是验证失败密码前有回显字符/杂输出干扰用单引号或花括号把密码字符串固定下来禁止转义中文系统乱码编码不一致脚本首行加export LANGen_US.UTF-8或用-c设置编码9.3 安全与隐私密码硬编码的替代方案前面的脚本里密码全是明文写在脚本里的。这对于个人学习没问题但放到团队共享或者生产环境里就是个安全隐患。我见过项目里因为一个脚本里硬编码了服务器密码结果整个公司测试环境被渗透的案例真的不是危言耸听。有几个实用方案可以避免明文密码从命令行传入进程结束后即失效。最简单的方案脚本不保存密码由调用方或CI系统动态传入。set pass [lindex $argv 2]从环境变量读取set pass $env(TEST_PASSWORD)调用前设置一次环境变量脚本内部读取密码不会出现在命令行历史和脚本文件里。从单独的配置文件读取配置文件权限设为600set fp [open /home/user/.expect_auth.conf r] set pass [gets $fp] close $fp个人使用我最推荐第二种和第三种安全性和便利性都比较平衡。团队协作时配合密钥管理服务比如HashiCorp Vault来做动态取密会更规范但那种重方案适合大型团队小项目用环境变量就足够了。9.4 expect脚本的编码规范建议写Expect脚本跟写其他代码一样也需要一套约定否则时间一长你自己都看不懂自己写的脚本。我给自己定的规范如下文件名统一.exp后缀一眼识别。脚本头部必须有注释说明用途、参数顺序、示例。敏感信息一律通过参数或环境变量传入。每个send命令前写清“为什么发这条命令”。expect分支至少包含成功、失败、超时三种情况。流程中用send_user打印关键进度方便实时观察。proc函数超过5个时把公共部分抽成单独文件用source引入。这些规范看上去很简单但它们确保了半年前的脚本现在还能被同事快速接手。我在团队里推行这套规范以后Expect相关的排障时间明显缩短因为大家读脚本时不会再去猜“当时我到底为什么不加那个\r”。10. 把Expect与自动化测试框架集成——pytest的搭档体验与完整实例很多做自动化测试的朋友会问都已经用pytest了还要Expect干什么这里其实存在一个很普遍的误解。pytest擅长的是编排测试用例、管理断言、生成报告但它本身不具备“操控交互式终端”的能力。当测试用例里出现“需要SSH登录”“需要串口交互”“需要处理交互式问答”这种环节时pytest会显得束手无策——它不处理PTY也不管密码提示。而Expect刚好填补了这块能力。合理的搭配是顶层用pytest管理用例底层用Expect封装所有交互式操作通过Python的subprocess模块调用Expect脚本再把结果返回给pytest做断言。举一个实际例子。假设有一个DDR测试流程登录板卡、写寄存器、跑内存测试、读取结果。如果全部用pytest写会非常别扭因为你得在Python里用pexpect这种库重新实现一遍终端交互逻辑。但如果先用Expect把每一步封装成一个独立脚本再在pytest里用下面这种形式调用import subprocess def test_ddr_temperature(): # 调用Expect脚本读取板卡温度 result subprocess.run( [expect, /opt/test_scripts/read_temp.exp, root, 192.168.1.50], capture_outputTrue, textTrue, timeout30, ) output result.stdout.strip() temp_value extract_temperature_from_output(output) assert temp_value 85, fDDR温度过高{temp_value}℃ def test_ddr_register_loopback(): # 调用Expect脚本做寄存器回环测试 result subprocess.run( [expect, /opt/test_scripts/reg_loopback.exp, root, 192.168.1.50], capture_outputTrue, textTrue, timeout60, ) assert LOOPBACK_PASS in result.stdout这种方式的好处很直接pytest负责用例调度Expect负责终端交互各司其职。你不需要在Python里费力模拟交互式终端也用不着在Expect脚本里处理复杂的断言逻辑。这个模式我用了很久稳定性和可维护性都相当好。另外配合pytest的fixture机制还可以在测试准备阶段用Expect脚本完成环境部署在清理阶段用Expect脚本恢复设备状态整个测试流程变得非常干净。比如pytest.fixture(scopemodule, autouseTrue) def setup_board(): subprocess.run([expect, setup_board.exp, 192.168.1.50], checkTrue) yield subprocess.run([expect, clean_board.exp, 192.168.1.50], checkTrue)这样一来每个用例开头都在统一环境里跑完自动恢复对多用例串联的验证套件来说极其省心。11. 我在实际项目中总结的Expect避坑清单最后把那些年踩过的坑集中整理一下每一条都在真实项目中遇到过。这些内容在大多数Expect教程里根本不会写属于实操收益最高的部分。11.1 回车符不是\n而是\r这个坑前面提过但值得再强调一遍。真实终端的回车键对应的就是CRCarriage Return回车ASCII 13代码里写\r键盘上的Enter键发的是\r\n的组合。Expect里send是发送到伪终端最贴近真实键盘行为的是只发\r。许多刚从C语言或者Python转过来的朋友会下意识用\n结果发现有的程序能跑通有的程序就是不对——试着统一改成\r大多数问题会消失。11.2 不要用管道echo | expect的方式去跑交互程序有些人习惯用echo cmd | expect script.exp想直接给脚本喂参数但这个做法在Expect场景下很不稳定。希望接收输入的是subprocess比如SSH而Expect脚本自己也要读输入。最好的方式是让Expect脚本自己spawn命令而不是依靠外层shell的管道输入。外层管道会把当前shell的stdin弄成非终端状态Expect的PTY链路反而乱了。如果你实在想从一个环境变量传给Expect脚本就用前面说的$env(名字)方案。11.3 全局匹配使用exp_continue时防止死循环exp_continue可以在一组expect中不断重新匹配新输出。如果写得不好比如匹配了一个永远会再次出现的字符串就会来回循环消耗CPU并浪费日志。一个经典案例是在串口登录后login:和Password:只出现一次循环没问题但如果后台有些程序会间歇性输出相同字符串死循环风险就真实存在了。我的习惯是做一个计数器达到上限就强制退出set attempt 0 expect { -re login: { if {$attempt 3} { send root\r incr attempt exp_continue } else { send_user 登录尝试次数过多退出\n exit 1 } } -re Password: { send ...\r exp_continue } -re # { send_user 登录成功\n } }这样做不仅防止了死循环也提升了脚本面对异常时的鲁棒性。11.4 密码里包含特殊字符时的转义地狱假如密码是Pssw0rd!这个!在Tcl的历史替换规则里有特殊含义在部分Expect版本中可能导致不可预期的行为。安全的做法是尽量用花括号包裹密码字符串set pass {Pssw0rd!}花括号在Tcl里的作用跟单引号在shell里类似花括号内所有内容都不做变量替换和特殊字符解释。如果你的密码里出现空格、引号、$、[、]这些符号都建议用花括号包起来或者直接用环境变量方式传入彻底绕开转义问题。11.5 串口工具存在半秒握手延迟用picocom或minicom连接串口时打开串口后设备端会有大概0.1-0.5秒的握手时间。如果Expect脚本在spawn之后立即send第一条命令极大概率会被串口设备丢弃。稳妥做法是spawn串口后先加一个短暂的sleepspawn picocom -b 115200 /dev/ttyUSB0 sleep 1 send \r expect -re login:|#|这个细节在手工操作时感觉不到但自动化脚本里缺失了会导致“第一条命令莫名其妙不生效”。在自动化调试阶段多留1秒延迟比反复排查“为什么没收到”要划算得多。11.6 慎用interact做纯自动化interact是把控制权还给用户的。一旦执行了interact原先的自动流程就停住了Expect脚本不再自动发送任何内容。如果需要继续自动化必须在interact里自定义一个返回的按键。如果你完全不想让人参与就永远不要用interact用expect eof等程序自然结束即可。这个坑看起来简单但实际项目里确实有同事把interact写进自动化流水线导致测试卡在登录后一动不动排查了半天才发现是脚本在等人输命令。11.7 大输出量时加缓冲时间如果你让板卡执行一个会连续输出几万行的命令比如dmesgExpect的PTY缓冲区有可能瞬间被打满而你的脚本才匹配到第一行就往下走了导致后面的关键输出被吞掉。解决办法有两个一是去掉不必要的回显用log_user 0加log_file写文件让输出直接进文件而不是被模式匹配盯着二是对超长输出场景采用“等待特定结束标识”的方式比如遇到dmesg_end或日志尾部标记再继续。防护方法是先让对方把完整日志写到文件里Expect只负责等待“写入完成”的信号然后读取文件分析。这个思路在芯片压力测试里非常常用实测比我硬用Expect模式扫描完整输出要稳一个数量级。11.8 用Expect管理多个目标时的状态隔离当你在一个Expect进程里先后spawn多个不同的程序时务必注意它们之间的状态不会自动隔离。PTY缓冲区、变量值、超时计数器都是共享的。如果一个程序跑完后没做清理下一个程序spawn时就可能带着之前的残留数据导致匹配错乱。我的规范是一个Expect脚本尽量只负责一条完整链路如果确实需要串多步每步之间清空缓冲区状态并在关键匹配之前用exp_continue把不需要的内容快速消费掉。12. 这套工具的最终价值总结成一句话Expect这门工具已经有三十多年历史但你翻遍派不上用场的教程会发现它如今的价值其实被大多数人严重低估了。在芯片验证、设备自动化、服务器运维这些领域每天仍有大量工作卡在“交互式程序没法自动应答”这个问题上。而Expect给出的思路——用PTY模拟终端、用模式匹配感知程序状态、用自动发送完成信息闭环——在解决这类问题上至今依然是最好的办法。我个人的习惯是把Expect当成“打破工具边界”的插件来用任何命令行工具只要能跑通一次我就能用Expect包一层让它变得可以自动操控。串口登录、uboot命令、SSH远程执行、工具交互问答这些环节一旦全部打通整套测试流程的自动化程度会得到一个质的飞升。如果你想从今天开始掌握它不需要看几百页文档就照着这篇笔记从最简的SSH自动登录脚本开始把它跑通然后加参数、加日志、加重试。等到你能独立封装一个带串口登录、寄存器读写、日志采集的完整脚本时你基本就到达了“能用”的进阶水平。再往后遇到任何需要交互式自动化的新工具你都会有一种“这东西我熟悉只是包一层Expect而已”的底气。那才是掌握了这门工具的真正标志。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案