☰
Expect交互式自动化脚本实战:SSH、串口与芯片验证全攻略
2026/9/29 15:38:52 网站建设 项目流程

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 expect

CentOS/RHEL系的机器用:

sudo yum install -y expect

macOS上如果你装了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:启动一个被监控的进程,而不是普通的fork

spawn的底层行为跟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 root@192.168.1.100 spawn ./flash_tool --config ddr4.ini spawn telnet 192.168.1.50 8080

spawn执行完后不会等进程结束,而是立即返回,让Expect脚本继续往下执行expect语句去匹配输出。这一点要注意:如果你紧接着写的expect要匹配的内容比较长,但程序输出很快,可能匹配过程会遇到超时问题。解决办法就是调大set timeout,或者用正则匹配里更灵活的方式。

4.2 expect:核心匹配机制,不是简单的字符串查找

expect命令做的事情是:从PTY输出缓冲区中读数据,逐一跟给定模式匹配,匹配成功就执行对应的动作,匹配失败则一直等到超时。这里的“模式”有两种形态,搞懂它们的区别是写脚本的关键:

  1. 精确字符串匹配。比如:
expect "password:"

这是最简单也最常用的写法。注意,这里匹配的是输出流中的子串,不是逐字符严格相等。只要屏幕输出的内容包含password:这个子串,就算匹配成功。所以Please enter password:里包含了password:,也能命中。

  1. 正则表达式匹配。在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(也就是伪终端)的输入流上。一个经典新手错误是忘了加回车符,导致程序收到了字符串但不知道你已经输完了。如果提示符要求你输入完按回车,那字符串末尾一定要加\r:

send "admin\r"

这里有个细节:为什么是\r而不是\n?因为在真实终端里,回车键发送的是回车符CR(\r),而换行符LF(\n)是程序输出时才用的。如果你发\n,部分程序也能正确解析,但最稳妥、最“真人”的做法是发\r。我之前就碰到过一个加密工具对换行符特别敏感,\n会让它把密码跟换行一起当作密码内容,导致认证失败,改成\r后立刻正常。这种小坑,文档里往往不会写,只有踩过才知道。

如果只是想向终端输出调试信息,而不是模拟键盘输入,应该用send_user:

send_user "开始执行第2轮测试...\n"

send跟send_user的区别一定要分清,前者是发给子进程的输入流,后者是写给用户(也就是屏幕)的。搞混了就会出现“屏幕上看到一堆乱码,程序那边啥也没收到”的诡异现象。

4.4 expect eof:把控制权交给屏幕,还是优雅收尾?

expect eof的作用是等待子进程退出,并让Expect脚本把PTY缓冲区的后续输出“倒”到屏幕上,保证你还能看到程序最后的完整打印。很多初学者省略这句话,结果脚本跑完屏幕上什么都没有,以为自己写错了。实际上不是写错了,而是Expect把进程的输出缓冲跟脚本自己的输出隔离了,你不主动消费那个缓冲区,就看不到任何东西。

以下三种收尾方式,按使用频率排序:

# 1. 等程序自然结束 expect eof # 2. 把剩余控制权还给用户(交互式结束) interact # 3. 不等了,强制结束程序 close

interact在自动化阶段结束后特别好用——比如你用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 StrictHostKeyChecking=no $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 StrictHostKeyChecking=no用来跳过首次连接的主机指纹确认,这是自动化场景的标准操作。如果你觉得这样不够安全,也可以第一次先手动确认一次,后续再自动化。
  • (?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" P@ssw0rd

脚本内部用$argv取得参数列表后,再通过lindex分配变量。我在实际项目里还会做一个参数个数校验:

if {[llength $argv] != 4} { send_user "用法:$argv0 <用户> <IP> <命令> <密码>\n" exit 1 }

$argv0在Tcl里是当前脚本文件名。有了这个校验,别人拿到你的脚本就不会傻傻地一次性传错参数还不知道问题出在哪。

5.4 SSH连不上的常见原因排查思路

自动登录出现异常时,不要急着调Expect代码,先按这个顺序排查:

  1. 手动试一下能否登录。用同样的IP、用户、密码手动执行一次,如果手动都登不上,那就是网络、账号、密码本身的问题,比如IP不可达、密码过期、远程主机拒绝root登录,这些都不是Expect的锅。
  2. 确认提示符文本。不同系统的SSH密码提示可能是Password:、password for user:或者中文系统里的备用提示。用-re+ 忽略大小写能覆盖大多数情况。
  3. 检查超时设置。默认timeout是10秒,如果你的网络比较慢,很可能会在密码提示出现之前就超时了。调到20-30秒一般就稳了。
  4. 把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交互与固件烧录自动化

芯片验证中另一类高频交互场景是uboot(U-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的提示符,每执行完一条命令,提示符变化表示命令已经执行结束。这里要特别强调的是,每条命令发完之后都必须等待提示符重新出现,再发下一条命令。如果不停顿地连续send,uboot那边还没处理完上一条命令,你下一条指令就“挤”进来了,轻则命令丢失,重则状态错乱。这个“命令-等待-反馈-再命令”的循环,就是所有交互式自动化脚本的节奏感所在。

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 {value=0x([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、echo)5-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 {$i+1}] 次测试FAIL\n" } timeout { send_user "第 [expr {$i+1}] 次超时\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 2>&1 & done wait echo "所有板子测试结束"

每个Expect脚本负责一块板子的全流程,后台并发执行,最后统一汇总结果。这样做最大的好处是单脚本的复杂度不增加,并发能力是shell直接给的。我在一个验证项目里用这种方式同时管理过20多块板子,效果非常稳定。

如果你希望更精细地控制并发,比如“同时最多跑5个”“每个结束后自动领下一个任务”,那可能需要引入更完整的工作流工具,比如Jenkins的并发构建或自研调度脚本。但对大多数场景来说,shell + Expect的组合已经足够。

8. 复杂场景处理:interact、子进程嵌套和正则提取

8.1 interact:自动化与手动操作的交接棒

一个典型场景:自动化登录服务器后想自己做些临时操作。前面脚本跑到最后全是expect eof,等程序自己结束,但有时候你不想让程序结束,你想接管。这就轮到interact上场:

spawn ssh root@192.168.1.100 expect "password:" send "mypassword\r" expect "#" send_user "自动化登录完成,下面交给手动操作\n" interact

interact会把终端的控制权完全交给用户,Expect终止对PTY的监控,此时你就像直接SSH登录上去一样,所有按键都发给远程进程。如果后续还想再回到自动化,可以用interact加返回触发器,比如按Ctrl+]回到Expect里继续操作。这在多级跳板登录、需要临时确认硬件状态的场景下特别好用。

8.2 嵌套Expect:控制一个再控制一个

你可能碰到过这样一个流程:先登录跳板机,再从跳板机SSH到内网服务器,内网服务器上再登录某个工具的控制台。这么深的嵌套,单个spawn显然不够。Expect里有两个思路:

第一个是反复spawn,前一个进程结束后再启动下一个。比如:

spawn ssh user@jump_host expect "password:" send "jump_pass\r" expect "#" # 在跳板机上敲一条命令登录内网服务器 send "ssh root@server1\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/xxx
expect: invalid command name命令拼写错或Expect版本过老检查拼写,更新Expect
Expect Script timed outtimeout设短了调大timeout或设set timeout -1永不超时
脚本执行完没有任何输出少了expect eof或log_user被设成0检查收尾语句和log_user设置
程序收到了输入但没反应send末尾漏了\r检查send字符串里的回车符
密码总是验证失败密码前有回显字符/杂输出干扰用单引号或花括号把密码字符串固定下来,禁止转义
中文系统乱码编码不一致脚本首行加export LANG=en_US.UTF-8,或用-c设置编码

9.3 安全与隐私:密码硬编码的替代方案

前面的脚本里密码全是明文写在脚本里的。这对于个人学习没问题,但放到团队共享或者生产环境里就是个安全隐患。我见过项目里因为一个脚本里硬编码了服务器密码,结果整个公司测试环境被渗透的案例,真的不是危言耸听。

有几个实用方案可以避免明文密码:

  1. 从命令行传入,进程结束后即失效。最简单的方案,脚本不保存密码,由调用方或CI系统动态传入。
set pass [lindex $argv 2]
  1. 从环境变量读取:
set pass $env(TEST_PASSWORD)

调用前设置一次环境变量,脚本内部读取,密码不会出现在命令行历史和脚本文件里。

  1. 从单独的配置文件读取,配置文件权限设为600:
set 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_output=True, text=True, timeout=30, ) output = result.stdout.strip() temp_value = extract_temperature_from_output(output) assert temp_value < 85, f"DDR温度过高:{temp_value}℃" def test_ddr_register_loopback(): # 调用Expect脚本做寄存器回环测试 result = subprocess.run( ["expect", "/opt/test_scripts/reg_loopback.exp", "root", "192.168.1.50"], capture_output=True, text=True, timeout=60, ) assert "LOOPBACK_PASS" in result.stdout

这种方式的好处很直接:pytest负责用例调度,Expect负责终端交互,各司其职。你不需要在Python里费力模拟交互式终端,也用不着在Expect脚本里处理复杂的断言逻辑。这个模式我用了很久,稳定性和可维护性都相当好。

另外,配合pytest的fixture机制,还可以在测试准备阶段用Expect脚本完成环境部署,在清理阶段用Expect脚本恢复设备状态,整个测试流程变得非常干净。比如:

@pytest.fixture(scope="module", autouse=True) def setup_board(): subprocess.run(["expect", "setup_board.exp", "192.168.1.50"], check=True) yield subprocess.run(["expect", "clean_board.exp", "192.168.1.50"], check=True)

这样一来,每个用例开头都在统一环境里,跑完自动恢复,对多用例串联的验证套件来说极其省心。

11. 我在实际项目中总结的Expect避坑清单

最后把那些年踩过的坑集中整理一下,每一条都在真实项目中遇到过。这些内容在大多数Expect教程里根本不会写,属于实操收益最高的部分。

11.1 回车符不是\n而是\r

这个坑前面提过,但值得再强调一遍。真实终端的回车键对应的就是CR(Carriage 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 密码里包含特殊字符时的转义地狱

假如密码是P@ssw0rd!,这个!在Tcl的历史替换规则里有特殊含义,在部分Expect版本中可能导致不可预期的行为。安全的做法是尽量用花括号包裹密码字符串:

set pass {P@ssw0rd!}

花括号在Tcl里的作用跟单引号在shell里类似,花括号内所有内容都不做变量替换和特殊字符解释。如果你的密码里出现空格、引号、$、[、]这些符号,都建议用花括号包起来,或者直接用环境变量方式传入,彻底绕开转义问题。

11.5 串口工具存在半秒握手延迟

用picocom或minicom连接串口时,打开串口后设备端会有大概0.1-0.5秒的握手时间。如果Expect脚本在spawn之后立即send第一条命令,极大概率会被串口设备丢弃。稳妥做法是spawn串口后先加一个短暂的sleep:

spawn 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 大输出量时加缓冲时间

如果你让板卡执行一个会连续输出几万行的命令(比如dmesg),Expect的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而已”的底气。

那才是掌握了这门工具的真正标志。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询