3、Tcl语言基础(下):控制流、过程定义、文件操作

好,咱们接着聊Tcl。上一章我们把变量、列表、字符串这些基本功过了一遍。这一章,我要带你进入真正能“干活”的部分——控制流、过程定义和文件操作。

说白了,前面学的都是“材料”,这一章学的是“怎么用这些材料搭房子”。在仿真脚本里,你要根据不同的仿真条件走不同的分支,要循环跑多个测试用例,要把重复的代码封装成过程,还要读写文件来记录结果。这些,都是日常逃不掉的操作。

3.1 控制流:让脚本有“脑子”

控制流就是让脚本能“做决策”。没有控制流,脚本就是一串从头跑到尾的线性命令,遇到复杂场景就傻眼了。

3.1.1 if 条件判断

if 是最基本的判断结构。语法很简单,但有个坑我当年踩过——if 后面的 { 必须和 if 在同一行,否则Tcl会报错。

set sim_mode "functional"

if {$sim_mode eq "functional"} {
    puts "运行功能仿真"
} elseif {$sim_mode eq "timing"} {
    puts "运行时序仿真"
} else {
    puts "未知仿真模式"
}

我的习惯: 写 if 的时候,我总会在条件表达式外面加一层花括号。比如 if {$x > 5} {,而不是 if $x > 5 {。为什么?因为花括号能防止变量被提前展开,避免一些奇怪的副作用。这个习惯帮我省了不少调试时间。

3.1.2 switch 多分支选择

当分支超过三四个的时候,if-elseif 写起来就有点啰嗦了。这时候 switch 更清爽。

set testcase "uart_test"

switch $testcase {
    "uart_test" {
        puts "运行UART测试"
        source "scripts/uart_test.tcl"
    }
    "spi_test" {
        puts "运行SPI测试"
        source "scripts/spi_test.tcl"
    }
    "i2c_test" {
        puts "运行I2C测试"
        source "scripts/i2c_test.tcl"
    }
    default {
        puts "未识别的测试用例: $testcase"
    }
}

嗯,这里要注意:switch 默认是精确匹配。如果你想用通配符匹配,可以加 -glob 选项;想用正则表达式,加 -regexp。我在项目中经常用 -glob 来匹配一类测试用例,比如 test_*。

3.1.3 while 循环

while 循环适合“不知道要循环多少次,但知道什么时候停”的场景。比如等待仿真结束。

set sim_done 0
set timeout 1000
set count 0

while {!$sim_done && $count < $timeout} {
    after 10  ;# 等待10毫秒
    set sim_done [check_sim_status]
    incr count
}

if {$sim_done} {
    puts "仿真正常结束"
} else {
    puts "仿真超时!"
}

我曾经踩过的坑: 写 while 循环一定要加超时保护!有一次我写了个死循环,忘了加退出条件,结果脚本跑了整整一个周末,把服务器资源吃光了。从那以后,我所有的 while 循环都会配一个计数器或者超时变量。

3.1.4 foreach 遍历循环

foreach 是我个人用得最多的循环。遍历列表、遍历文件、遍历测试用例……几乎天天用。

set test_list {uart_test spi_test i2c_test gpio_test}

foreach test $test_list {
    puts "正在运行: $test"
    source "scripts/${test}.tcl"
    puts "$test 完成"
}

foreach 还有一个“多变量遍历”的用法,可以同时遍历多个列表:

foreach test_name $test_list expected_result $expected_list {
    set actual [run_test $test_name]
    if {$actual eq $expected_result} {
        puts "$test_name: 通过"
    } else {
        puts "$test_name: 失败 (期望 $expected_result, 实际 $actual)"
    }
}

3.2 过程定义:把重复代码“包起来”

写脚本最怕什么?最怕复制粘贴。今天复制一段,明天粘贴一段,后天改需求的时候,你就得满世界找那些复制粘贴的代码,一个一个改。累不累?

所以,把重复的逻辑封装成过程(procedure),是仿真脚本工程化的第一步。

3.2.1 proc 的基本用法

proc run_simulation {test_name {timeout 1000}} {
    puts "启动仿真: $test_name"
    puts "超时设置: ${timeout}ms"
    
    # 这里放实际的仿真启动逻辑
    # ...
    
    return "PASS"
}

# 调用
set result [run_simulation "uart_test"]
set result [run_simulation "spi_test" 2000]

看到没?{timeout 1000} 这种写法表示给参数一个默认值。调用的时候可以不传这个参数,它会自动用1000。这个特性在写仿真脚本时特别实用——大部分测试用默认超时就行,个别慢的测试单独设置。

3.2.2 局部变量与全局变量

过程内部默认是局部作用域。想访问全局变量?用 global 声明一下。

set sim_log_file "simulation.log"

proc log_message {msg} {
    global sim_log_file
    set fp [open $sim_log_file a]
    puts $fp "[clock format [clock seconds]]: $msg"
    close $fp
}

log_message "仿真开始"
log_message "测试用例执行完毕"

我建议: 尽量少用全局变量。全局变量多了,脚本的耦合度就高了,改起来容易出问题。我一般会把全局变量集中放在一个配置文件中,用 source 加载,而不是散落在各个过程里。

3.2.3 过程返回值

过程默认返回最后一个命令的执行结果。你也可以用 return 显式返回。

proc compare_results {golden_file result_file} {
    set golden [read_file $golden_file]
    set result [read_file $result_file]
    
    if {$golden eq $result} {
        return 1  ;# 匹配
    } else {
        return 0  ;# 不匹配
    }
}

if {[compare_results "golden.txt" "result.txt"]} {
    puts "结果一致,测试通过"
} else {
    puts "结果不一致,测试失败"
}

3.3 文件操作:读写数据

仿真脚本离不开文件操作。你要读测试向量、写日志、保存波形、生成报告……可以说,文件操作是仿真脚本的“输入输出口”。

3.3.1 打开与关闭文件

set fp [open "simulation.log" w]  ;# w: 写入, r: 读取, a: 追加
puts $fp "仿真日志开始"
close $fp

这里有个细节:open 返回的是文件描述符(一个整数),不是文件名。后面所有操作都用这个描述符。

3.3.2 读取文件内容

# 逐行读取
set fp [open "test_vector.txt" r]
while {[gets $fp line] >= 0} {
    puts "读取到: $line"
    # 处理每一行数据
}
close $fp

# 一次性读取全部内容
set fp [open "config.txt" r]
set content [read $fp]
close $fp
puts "配置文件内容: $content"

核心要点: gets 命令读取一行,返回读取的字符数。如果返回 -1,说明到了文件末尾。这个返回值可以用来控制循环。

3.3.3 写入文件

set fp [open "report.txt" w]
puts $fp "测试报告"
puts $fp "=========="
puts $fp "测试用例: uart_test"
puts $fp "结果: PASS"
puts $fp "时间: [clock format [clock seconds]]"
close $fp

3.3.4 文件操作的综合示例

来个实际点的例子。假设我们要写一个脚本,读取测试向量文件,逐条运行仿真,然后把结果写入报告。

# 打开测试向量文件
set vec_fp [open "test_vectors.txt" r]
# 打开结果报告文件
set rpt_fp [open "test_report.txt" w]

puts $rpt_fp "仿真测试报告"
puts $rpt_fp "生成时间: [clock format [clock seconds]]"
puts $rpt_fp "----------------------------------------"

set pass_count 0
set fail_count 0

while {[gets $vec_fp line] >= 0} {
    # 跳过空行和注释行
    if {$line eq "" || [string index $line 0] eq "#"} {
        continue
    }
    
    # 解析测试向量,格式: 测试名,输入值,期望输出
    set fields [split $line ","]
    set test_name [lindex $fields 0]
    set input_val [lindex $fields 1]
    set expected  [lindex $fields 2]
    
    # 运行仿真(这里用模拟代替)
    set actual [run_sim_core $input_val]
    
    # 比较结果
    if {$actual eq $expected} {
        puts $rpt_fp "$test_name: PASS"
        incr pass_count
    } else {
        puts $rpt_fp "$test_name: FAIL (期望 $expected, 实际 $actual)"
        incr fail_count
    }
}

close $vec_fp

puts $rpt_fp "----------------------------------------"
puts $rpt_fp "总计: [expr {$pass_count + $fail_count}]"
puts $rpt_fp "通过: $pass_count"
puts $rpt_fp "失败: $fail_count"
close $rpt_fp

puts "测试完成,报告已生成: test_report.txt"

3.4 本章知识体系

我把这一章的核心内容画了张图,方便你理解各个知识点之间的关系。

Tcl语言基础(下)知识体系 控制流 过程定义 文件操作 if 条件判断 switch 多分支 while 条件循环 foreach 遍历循环 proc 定义过程 参数与默认值 局部/全局变量 return 返回值 open / close gets / read 读取 puts 写入 综合读写示例 三者结合:用控制流做决策 用过程封装逻辑,用文件操作读写数据

这一章的内容,说白了就是三个字:控、封、读。控制流程让脚本有逻辑,过程封装让代码可复用,文件操作让数据能流通。这三样东西组合起来,你就能写出真正能用的仿真脚本了。

我个人觉得,学Tcl最忌讳的就是“背语法”。语法是死的,场景是活的。你想想看,你写脚本是为了解决仿真中的实际问题,不是为了考试。所以,多想想“这个场景用哪个结构最合适”,比死记硬背重要得多。

好,这一章就到这里。代码示例我都跑过,放心用。遇到问题,多看看错误信息,Tcl的错误提示其实挺友好的。


可可精品资料库