使用fsanitize时如何捕获崩溃子进程的stdout输出?
我之前碰到过几乎一模一样的情况,核心问题出在标准输出的缓冲策略上,和Valgrind与ASAN的运行机制差异直接相关。
问题根源
默认情况下,C程序的stdout在连接终端时是行缓冲(遇到换行就自动刷新),但如果是连接到管道(比如你的Rust程序用Stdio::piped()创建的管道),就会切换为全缓冲——只有当缓冲区被填满、程序主动调用fflush,或者进程正常退出时,缓冲里的内容才会写入管道。
你的C程序在输出output1 from c后立刻触发栈溢出崩溃,这行内容还留在子进程的用户态缓冲里,根本没来得及写入管道。而Valgrind运行时会接管进程的IO流程,可能强制修改了缓冲策略,或者在进程崩溃时帮你自动刷新了缓冲,所以之前用Valgrind能拿到完整输出。
另外要注意:ASAN的错误信息是输出到stderr的,你当前的代码只读取了stdout,所以这部分内容也会丢失,需要一起处理。
解决方案
下面按可行性从高到低给几个方案:
1. 强制子进程stdout无缓冲(无需修改被测C程序)
可以用stdbuf命令包装你的被测程序,强制stdout使用无缓冲模式,这是最省心的方案:
修改Rust里的Command初始化代码:
let mut run_cmd = Command::new("stdbuf") .args(["-o0", &format!("./{}", &self.meta.projdata.project_name)]) // -o0 表示stdout无缓冲 .current_dir( &self .meta .projdata .makefile_path .as_ref() .unwrap_or(&String::from("./")) ) .args([&self.argv].iter().filter(|s| !s.is_empty())) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .envs(envs) .spawn() .expect("could not spawn process");
stdbuf是GNU coreutils自带的工具,几乎所有Linux系统都预装了,它能在不修改程序代码的前提下改变标准IO的缓冲策略。
2. 修改被测C程序主动刷新缓冲
如果你有权限修改被测的C代码,在输出后手动刷新stdout即可:
#include <stdio.h> int main() { char array[1]; puts("output1 from c"); fflush(stdout); // 强制刷新stdout缓冲,确保内容写入管道 array[20000] = 'c'; printf("output2 from c: %llu\n", (long long unsigned) (array[20000]) ); return 0; }
这样即使程序立刻崩溃,之前的输出也会被写入管道,Rust程序就能正常读取。
3. 改进Rust的IO读取逻辑(异步读取)
当前你的代码是先wait_timeout再读取stdout,这种方式在进程崩溃时可能错过缓冲数据。更好的做法是在进程运行时,用线程同时读取stdout和stderr,确保所有输出都被及时捕获:
use std::io::{Read, BufRead, BufReader}; use std::thread; // 可以自定义错误类型,带上超时情况下的已捕获输出 #[derive(Debug)] enum ExecuteError { Timeout, TimeoutWithOutput(String, String), } fn command_timeout( mut cmd: std::process::Child, timeout: i32, number: i32, ) -> Result<(String, String, i32), ExecuteError> { // 启动线程读取stdout let mut stdout = cmd.stdout.take().unwrap(); let stdout_handle = thread::spawn(move || { let mut reader = BufReader::new(stdout); let mut output = String::new(); let _ = reader.read_to_string(&mut output); output }); // 启动线程读取stderr(捕获ASAN的错误信息) let mut stderr = cmd.stderr.take().unwrap(); let stderr_handle = thread::spawn(move || { let mut reader = BufReader::new(stderr); let mut output = String::new(); let _ = reader.read_to_string(&mut output); output }); match cmd.wait_timeout(std::time::Duration::from_secs(timeout as u64)).unwrap() { Some(expr) => { let stdout_output = stdout_handle.join().unwrap(); let stderr_output = stderr_handle.join().unwrap(); let retvar = expr.code().unwrap_or(-1); Ok((stdout_output, stderr_output, retvar)) } None => { println!("killing {} because of timeout", number); cmd.kill().expect("Upps, can't kill this one"); // 即使超时也要读取已经输出的内容 let stdout_output = stdout_handle.join().unwrap(); let stderr_output = stderr_handle.join().unwrap(); Err(ExecuteError::TimeoutWithOutput(stdout_output, stderr_output)) } } }
这种方式能确保不管进程是正常退出还是崩溃,所有已经写入管道的输出都会被读取,同时也能完整捕获ASAN的stderr错误信息。
额外提示
你的测试用例预期结果包含ASAN的错误信息,所以在最终的测试对比中,需要把stdout和stderr的内容合并后再与预期值对比,或者分别验证两部分内容。
内容的提问来源于stack exchange,提问作者shanks3042

