У Unix-падобных аперацыйных сістэмах зносіны праграмы з навакольным светам і аперацыйнай сістэмай адбываецца праз невялікі набор функцый – сістэмных выклікаў. А значыць, у адладкавых мэтах карысна бывае падглядзець за выкананымі працэсамі сістэмнымі выклікамі.
Сачыць за "інтымным жыццём" праграм на Linux дапамагае ўтыліта strace, якой і прысвечаны гэты артыкул. Да прыкладаў выкарыстання "шпіёнскага" абсталявання прыкладаюцца кароткая гісторыя strace і апісанне прылады падобных праграм.
Галоўны інтэрфейс паміж праграмамі і ядром OC у Unix - сістэмныя выклікі (англ. сістэмныя выклікі, сістэмныя выклікі), узаемадзеянне праграм з навакольным светам адбываецца выключна праз іх.
Але ў першай публічнай версіі Unix (Version 6 Unix, 1975 год) зручных спосабаў адсочвання паводзінаў карыстацкіх працэсаў не было. Для вырашэння гэтай праблемы Bell Labs да наступнай версіі (Version 7 Unix, 1979 год) прапанавалі новы сістэмны выклік ptrace.
Распрацоўваўся ptrace перш за ўсё для інтэрактыўных адладчыкаў, але да канца 80-х (у эпоху камерцыйнага ўжо System V Release 4) на гэтай аснове з'явіліся і атрымалі найшырэйшае распаўсюджванне вузканакіраваныя адладчыкі - трасіроўшчыкі сістэмных выклікаў.
Першая жа версія strace была апублікаваная Полам Кроненбургам у рассылцы comp.sources.sun у 1992 году ў якасці альтэрнатывы зачыненай утыліце trace ад Sun. Як клон, так і арыгінал прызначаліся для SunOS, але да 1994 году strace была партаваная на System V, Solaris і які набірае папулярнасць Linux.
Сёння strace падтрымлівае толькі Linux і абапіраецца на ўсё той жа ptrace, аброс мноствам пашырэнняў.
Сучасны (і вельмі актыўны) мэйнтэйнер strace - Дзмітрый Левін. Дзякуючы яму ўтыліта абзавялася прасунутымі магчымасцямі накшталт ін'екцыі памылак у сістэмныя выклікі, падтрымкай шырокага спектра архітэктур і, галоўнае, маскотам. Неафіцыйныя крыніцы сцвярджаюць, што выбар упаў на страўса з-за сугучнасці рускага слова "страус" і англійскага "strace".
Немалаважна і тое, што сістэмны выклік ptrace і трасіроўшчыкі так і не былі ўключаны ў POSIX, нягледзячы на доўгую гісторыю і наяўнасць рэалізацыі ў Linux, FreeBSD, OpenBSD і традыцыйных Unix.
Прылада strace у двух словах: Piglet Trace
"Даніс Рычы, каментар у зыходным кодзе Version 6 Unix)
З ранняга дзяцінства я трываць не магу чорныя скрыні: з цацкамі я не гуляў, а спрабаваў разабрацца ў іх прыладзе (дарослыя ўжывалі слова "ламаў", але не верце злым мовам). Магчыма, таму мне такія блізкія нефармальная культура першых Unix і сучаснага open-source-руху.
У рамках гэтага артыкула разбіраць зыходны код разгнелага за дзесяцігоддзі strace неразумна. Але і таямніц для чытачоў заставацца не павінна. Таму, каб паказаць прынцып працы падобных strace праграм, я прывяду код мініятурнага трасіроўшчыка. Piglet Trace (ptr). Нічога асаблівага ён рабіць не ўмее, але галоўнае – сістэмныя выклікі праграмы – выводзіць:
Piglet Trace распазнае каля сотні сістэмных выклікаў Linux (гл. табліцу) і працуе толькі на архітэктуры x86-64. Для навучальных мэт гэтага дастаткова.
Давайце разбяром працу нашага клона. У выпадку з Linux для адладчыкаў і трасіроўшчыкаў выкарыстоўваецца, як згадвалася вышэй, сістэмны выклік ptrace. Ён працуе з дапамогай перадачы ў першым аргументе ідэнтыфікатараў каманд, з якіх нам патрэбны толькі PTRACE_TRACEME, PTRACE_SYSCALL и PTRACE_GETREGS.
Праца трасіроўшчыка пачынаецца ў звычайным Unix-стылі: fork(2) запускае даччыны працэс, а той у сваю чаргу з дапамогай exec(3) запускае доследную праграму. Адзіная тонкасць тут - выклік ptrace(PTRACE_TRACEME) перад exec: працэс-нашчадак чакае, што працэс-бацька будзе яго адсочваць:
pid_t child_pid = fork();
switch (child_pid) {
case -1:
err(EXIT_FAILURE, "fork");
case 0:
/* Child here */
/* A traced mode has to be enabled. A parent will have to wait(2) for it
* to happen. */
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
/* Replace itself with a program to be run. */
execvp(argv[1], argv + 1);
err(EXIT_FAILURE, "exec");
}
Працэс-бацька зараз павінен выклікаць wait(2) у даччыным працэсе, гэта значыць пераканацца, што пераключэнне ў рэжым трасіроўкі адбылося:
/* Parent */
/* First we wait for the child to set the traced mode (see
* ptrace(PTRACE_TRACEME) above) */
if (waitpid(child_pid, NULL, 0) == -1)
err(EXIT_FAILURE, "traceme -> waitpid");
На гэтым падрыхтоўцы скончаны і можна прыступаць непасрэдна да адсочвання сістэмных выклікаў у бясконцым цыкле.
выклік ptrace(PTRACE_SYSCALL) гарантуе, што наступны wait аднаго з бацькоў завершыцца альбо перад выкананнем сістэмнага выкліку, альбо адразу пасля яго завяршэння. Паміж двума выклікамі можна ажыццявіць якія-небудзь дзеянні: замяніць выклік на альтэрнатыўны, змяніць аргументы ці якое вяртаецца значэнне.
Нам жа дастаткова двойчы выклікаць каманду ptrace(PTRACE_GETREGS), каб атрымаць стан рэгістра rax да выкліку (нумар сістэмнага выкліку) і адразу пасля (вяртаецца значэнне).
Уласна, цыкл:
/* A system call tracing loop, one interation per call. */
for (;;) {
/* A non-portable structure defined for ptrace/GDB/strace usage mostly.
* It allows to conveniently dump and access register state using
* ptrace. */
struct user_regs_struct registers;
/* Enter syscall: continue execution until the next system call
* beginning. Stop right before syscall.
*
* It's possible to change the system call number, system call
* arguments, return value or even avoid executing the system call
* completely. */
if (ptrace(PTRACE_SYSCALL, child_pid, NULL, NULL) == -1)
err(EXIT_FAILURE, "enter_syscall");
if (waitpid(child_pid, NULL, 0) == -1)
err(EXIT_FAILURE, "enter_syscall -> waitpid");
/* According to the x86-64 system call convention on Linux (see man 2
* syscall) the number identifying a syscall should be put into the rax
* general purpose register, with the rest of the arguments residing in
* other general purpose registers (rdi,rsi, rdx, r10, r8, r9). */
if (ptrace(PTRACE_GETREGS, child_pid, NULL, ®isters) == -1)
err(EXIT_FAILURE, "enter_syscall -> getregs");
/* Note how orig_rax is used here. That's because on x86-64 rax is used
* both for executing a syscall, and returning a value from it. To
* differentiate between the cases both rax and orig_rax are updated on
* syscall entry/exit, and only rax is updated on exit. */
print_syscall_enter(registers.orig_rax);
/* Exit syscall: execute of the syscall, and stop on system
* call exit.
*
* More system call tinkering possible: change the return value, record
* time it took to finish the system call, etc. */
if (ptrace(PTRACE_SYSCALL, child_pid, NULL, NULL) == -1)
err(EXIT_FAILURE, "exit_syscall");
if (waitpid(child_pid, NULL, 0) == -1)
err(EXIT_FAILURE, "exit_syscall -> waitpid");
/* Retrieve register state again as we want to inspect system call
* return value. */
if (ptrace(PTRACE_GETREGS, child_pid, NULL, ®isters) == -1) {
/* ESRCH is returned when a child terminates using a syscall and no
* return value is possible, e.g. as a result of exit(2). */
if (errno == ESRCH) {
fprintf(stderr, "nTracee terminatedn");
break;
}
err(EXIT_FAILURE, "exit_syscall -> getregs");
}
/* Done with this system call, let the next iteration handle the next
* one */
print_syscall_exit(registers.rax);
}
Вось і ўвесь трасіроўшчык. Цяпер вы ведаеце, з чаго пачынаць чарговае партаванне DTrace на Linux.
Азы: запуск праграмы пад кіраваннем strace
У якасці першага прыкладу выкарыстання strace, мабыць, варта прывесці самы просты спосаб - запуск прыкладання пад кіраваннем strace.
Каб не капацца ў бясконцым спісе выклікаў тыповай праграмы, напішам мінімальную праграму вакол write:
int main(int argc, char *argv[])
{
char str[] = "write me to stdoutn";
/* write(2) is a simple wrapper around a syscall so it should be easy to
* find in the syscall trace. */
if (sizeof(str) != write(STDOUT_FILENO, str, sizeof(str))){
perror("write");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
Збяром праграму і пераканаемся, што яна працуе:
$ gcc examples/write-simple.c -o write-simple
$ ./write-simple
write me to stdout
Вельмі "шматслоўна" і не вельмі пазнавальна. Праблемы тут дзве: выснова праграмы змяшаны з высновай strace і багацце сістэмных выклікаў, якія нас не цікавяць.
Падзяліць стандартны струмень высновы праграмы і выснова памылак strace можна пры дапамозе ключа -o, які перанакіроўвае спіс сістэмных выклікаў у файл-аргумент.
Засталося разабрацца з праблемай "лішніх" выклікаў. Выкажам здагадку, што нас цікавяць толькі выклікі write. Ключ -e дазваляе паказваць выразы, па якіх будуць фільтравацца сістэмныя выклікі. Самы папулярны варыянт умовы - натуральна, trace=*, пры дапамозе якога можна пакінуць толькі цікавыя для нас выклікі.
Пры адначасовым выкарыстанні -o и -e мы атрымаем:
$ strace -e trace=write -owrite-simple.log ./write-simple
write me to stdout
$ cat write-simple.log
write(1, "write me to stdoutn", 20
) = 20
+++ exited with 0 +++
Так, пагадзіцеся, нашмат прасцей чытаецца.
А яшчэ можна прыбіраць сістэмныя выклікі - напрыклад, звязаныя з вылучэннем і вызваленнем памяці:
$ strace -e trace=!brk,mmap,mprotect,munmap -owrite-simple.log ./write-simple
write me to stdout
$ cat write-simple.log
execve("./write-simple", ["./write-simple"], 0x7ffe9972a498 /* 69 vars */) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=124066, ...}) = 0
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "177ELF21133>1260342"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2030544, ...}) = 0
close(3) = 0
arch_prctl(ARCH_SET_FS, 0x7f00f0be74c0) = 0
write(1, "write me to stdoutn", 20) = 20
exit_group(0) = ?
+++ exited with 0 +++
Звярніце ўвагу на экранаваны клічнік у спісе выключаных выклікаў: гэтага патрабуе камандная абалонка (англ. абалонка).
У маёй версіі glibc завяршае выкананне працэсу сістэмны выклік exit_group, а не традыцыйны _exit. У гэтым складаецца складанасць працы з сістэмнымі выклікамі: інтэрфейс, з якім працуе праграміст, не мае прамога стаўлення да сістэмных выклікаў. Больш за тое, ён рэгулярна мяняецца ў залежнасці ад рэалізацыі і платформы.
Азы: далучэнне да працэсу на лета
Першапачаткова сістэмны выклік ptrace, на якім пабудавана strace, можна было выкарыстоўваць толькі пры запуску праграмы ў спецыяльным рэжыме. Такое абмежаванне, магчыма, гучала разумна ў часы Version 6 Unix. У нашы ж дні гэтага ўжо недастаткова: бывае, трэба даследаваць праблемы працуючай праграмы. Тыповы прыклад - заблакаваны на дэскрыптары або спячы працэс. Таму сучасная strace умее далучацца да працэсаў на лета.
$ ./write-sleep &
[1] 15329
write me
$ strace -p 15329
strace: Process 15329 attached
pause(
^Cstrace: Process 15329 detached
<detached ...>
Праграма заблакаваная выклікам pause. Паглядзім, як яна адрэагуе на сігналы:
$ strace -o write-sleep.log -p 15329 &
strace: Process 15329 attached
$
$ kill -CONT 15329
$ cat write-sleep.log
pause() = ? ERESTARTNOHAND (To be restarted if no handler)
--- SIGCONT {si_signo=SIGCONT, si_code=SI_USER, si_pid=14989, si_uid=1001} ---
pause(
$
$ kill -TERM 15329
$ cat write-sleep.log
pause() = ? ERESTARTNOHAND (To be restarted if no handler)
--- SIGCONT {si_signo=SIGCONT, si_code=SI_USER, si_pid=14989, si_uid=1001} ---
pause() = ? ERESTARTNOHAND (To be restarted if no handler)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=14989, si_uid=1001} ---
+++ killed by SIGTERM +++
Мы запусцілі завіслую праграму і далучыліся да яе пры дапамозе strace. Высветліліся дзве рэчы: сістэмны выклік pause ігнаруе сігналы без апрацоўшчыкаў і, што цікавей, strace адсочвае не толькі сістэмныя выклікі, але і ўваходныя сігналы.
Прыклад: адсочванне даччыных працэсаў
Праца з працэсамі праз выклік fork - аснова ўсіх Unix. Давайце паглядзім, як strace працуе з дрэвам працэсаў на прыкладзе нескладанай «якая пладзіцца» праграмы:
int main(int argc, char *argv[])
{
pid_t parent_pid = getpid();
pid_t child_pid = fork();
if (child_pid == 0) {
/* A child is born! */
child_pid = getpid();
/* In the end of the day printf is just a call to write(2). */
printf("child (self=%d)n", child_pid);
exit(EXIT_SUCCESS);
}
printf("parent (self=%d, child=%d)n", parent_pid, child_pid);
wait(NULL);
exit(EXIT_SUCCESS);
}
Тут зыходны працэс стварае даччыны працэс, абодва пішуць у стандартны паток вываду:
Адсочваць дрэва працэсаў цалкам дапамагае сцяг -f, з якім strace адсочвае сістэмныя выклікі ў працэсах-нашчадках. Да кожнага радка вываду пры гэтым дадаецца pid працэсу, які робіць сістэмную выснову:
void do_write(int out_fd)
{
char str[] = "write me to a filen";
if (sizeof(str) != write(out_fd, str, sizeof(str))){
perror("write");
exit(EXIT_FAILURE);
}
}
int main(int argc, char *argv[])
{
char tmp_filename_template[] = "/tmp/output_fileXXXXXX";
int out_fd = mkstemp(tmp_filename_template);
if (out_fd == -1) {
perror("mkstemp");
exit(EXIT_FAILURE);
}
do_write(out_fd);
return EXIT_SUCCESS;
}
Пры звычайным выкліку strace пакажа значэнне колькасці-дэскрыптара, перададзенага ў сістэмны выклік:
$ strace -e trace=write -o write-tmp-file.log ./write-tmp-file
$ cat write-tmp-file.log
write(3, "write me to a filen", 20) = 20
+++ exited with 0 +++
Са сцягам -y утыліта паказвае шлях да файла, якому адпавядае дэскрыптар:
$ strace -y -e trace=write -o write-tmp-file.log ./write-tmp-file
$ cat write-tmp-file.log
write(3</tmp/output_fileCf5MyW>, "write me to a filen", 20) = 20
+++ exited with 0 +++
Прыклад: адсочванне зваротаў да файлаў
Яшчэ адна карысная магчымасць: адлюстроўваць толькі сістэмныя выклікі, злучаныя з пэўным файлам. Наступная праграма дапісвае радок у адвольны файл, перададзены ў аргуменце:
void do_write(int out_fd)
{
char str[] = "write me to a filen";
if (sizeof(str) != write(out_fd, str, sizeof(str))){
perror("write");
exit(EXIT_FAILURE);
}
}
int main(int argc, char *argv[])
{
/*
* Path will be provided by the first program argument.
* */
const char *path = argv[1];
/*
* Open an existing file for writing in append mode.
* */
int out_fd = open(path, O_APPEND | O_WRONLY);
if (out_fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
do_write(out_fd);
return EXIT_SUCCESS;
}
Па змаўчанні strace выводзіць шмат лішняй інфармацыі. Сцяг -P з аргументам прымушае strace выводзіць толькі звароты да паказанага файла:
$ strace -y -P/tmp/test_file.log -o write-file.log ./write-file /tmp/test_file.log
$ cat write-file.log
openat(AT_FDCWD, "/tmp/test_file.log", O_WRONLY|O_APPEND) = 3</tmp/test_file.log>
write(3</tmp/test_file.log>, "write me to a filen", 20) = 20
+++ exited with 0 +++
Прыклад: шматструменныя праграмы
ўтыліта strace можа дапамагчы і пры працы з шматструменнай праграмай. Наступная праграма піша ў стандартны паток вываду з двух патокаў:
void *thread(void *arg)
{
(void) arg;
printf("Secondary thread: workingn");
sleep(1);
printf("Secondary thread: donen");
return NULL;
}
int main(int argc, char *argv[])
{
printf("Initial thread: launching a threadn");
pthread_t thr;
if (0 != pthread_create(&thr, NULL, thread, NULL)) {
fprintf(stderr, "Initial thread: failed to create a thread");
exit(EXIT_FAILURE);
}
printf("Initial thread: joining a threadn");
if (0 != pthread_join(thr, NULL)) {
fprintf(stderr, "Initial thread: failed to join a thread");
exit(EXIT_FAILURE);
};
printf("Initial thread: done");
exit(EXIT_SUCCESS);
}
Збіраць яе трэба, натуральна, са адмысловым прывітаннем лінкоўшчыку - сцягам -pthread:
$ gcc examples/thread-write.c -pthread -o thread-write
$ ./thread-write
/thread-write
Initial thread: launching a thread
Initial thread: joining a thread
Secondary thread: working
Secondary thread: done
Initial thread: done
$
сцяг -f, як і ў выпадку са звычайнымі працэсамі, дадасць у пачатак кожнага радка pid працэсу.
Натуральна, гаворка ідзе не аб ідэнтыфікатары струменя ў сэнсе рэалізацыі стандарту POSIX Threads, а аб нумары, выкарыстоўваным планавальнікам задач у Linux. З пункта гледжання апошняга няма ніякіх працэсаў і струменяў ёсць задачы, якія трэба размеркаваць па даступных ядрах машыны.
Пры працы ў некалькі струменяў сістэмных выклікаў становіцца зашмат:
Дарэчы, пытанні. Які сістэмны выклік выкарыстоўваецца для стварэння новай плыні? Чым такі выклік для патокаў адрозніваецца ад выкліку для працэсаў?
Майстар-клас: стэк працэсу ў момант сістэмнага выкліку
Адна з нядаўна якія з'явіліся ў strace магчымасцяў - адлюстраванне стэка выклікаў функцый у момант сістэмнага выкліку. Просты прыклад:
void do_write(void)
{
char str[] = "write me to stdoutn";
if (sizeof(str) != write(STDOUT_FILENO, str, sizeof(str))){
perror("write");
exit(EXIT_FAILURE);
}
}
int main(int argc, char *argv[])
{
do_write();
return EXIT_SUCCESS;
}
Натуральна, выснова праграмы пры гэтым становіцца вельмі аб'ёмнай, і, акрамя сцяга -k (адлюстраванне стэка выклікаў), мае сэнс фільтраваць сістэмныя выклікі па імені:
$ gcc examples/write-simple.c -o write-simple
$ strace -k -e trace=write -o write-simple.log ./write-simple
write me to stdout
$ cat write-simple.log
write(1, "write me to stdoutn", 20) = 20
> /lib/x86_64-linux-gnu/libc-2.27.so(__write+0x14) [0x110154]
> /home/vkazanov/projects-my/strace-post/write-simple(do_write+0x50) [0x78a]
> /home/vkazanov/projects-my/strace-post/write-simple(main+0x14) [0x7d1]
> /lib/x86_64-linux-gnu/libc-2.27.so(__libc_start_main+0xe7) [0x21b97]
> /home/vkazanov/projects-my/strace-post/write-simple(_start+0x2a) [0x65a]
+++ exited with 0 +++
Майстар-клас: ін'екцыя памылак
І яшчэ адна новая і вельмі карысная магчымасць: ін'екцыя памылак. Вось праграма, якая піша два радкі ў струмень высновы:
Цікава, што памылкі вяртаюць ўсё выклікі write, у тым ліку выклік, схаваны за perror. Мае сэнс вяртаць памылку толькі для першага з выклікаў:
$ strace -e trace=write -e inject=write:error=EBADF:when=1 -owrite-twice.log ./write-twice
write: Bad file descriptor
$ cat write-twice.log
write(1, "write me 1n", 12) = -1 EBADF (Bad file descriptor) (INJECTED)
write(3, "write: Bad file descriptorn", 27) = 27
+++ exited with 1 +++
Або другога:
$ strace -e trace=write -e inject=write:error=EBADF:when=2 -owrite-twice.log ./write-twice
write me 1
write: Bad file descriptor
$ cat write-twice.log
write(1, "write me 1n", 12) = 12
write(1, "write me 2n", 12) = -1 EBADF (Bad file descriptor) (INJECTED)
write(3, "write: Bad file descriptorn", 27) = 27
+++ exited with 1 +++
Тып памылкі ўказваць не абавязкова:
$ strace -e trace=write -e fault=write:when=1 -owrite-twice.log ./write-twice
$ cat write-twice.log
write(1, "write me 1n", 12) = -1 ENOSYS (Function not implemented) (INJECTED)
write(3, "write: Function not implementedn", 32) = 32
+++ exited with 1 +++
У спалучэнні з іншымі сцягамі можна "ламаць" звароты да канкрэтнага файла. Прыклад:
$ strace -y -P/tmp/test_file.log -e inject=file:error=ENOENT -o write-file.log ./write-file /tmp/test_file.log
open: No such file or directory
$ cat write-file.log
openat(AT_FDCWD, "/tmp/test_file.log", O_WRONLY|O_APPEND) = -1 ENOENT (No such file or directory) (INJECTED)
+++ exited with 1 +++
Акрамя ін'екцый памылак, можна уводзіць затрымкі пры выкананні выклікаў ці атрыманні сігналаў.
пасляслоўе
ўтыліта strace - Просты і надзейны інструмент. Але апроч сістэмных выклікаў адладжваць здараецца і іншыя аспекты працы праграм і аперацыйнай сістэмы. Напрыклад, адсочваць выклікі дынамічна якія лінкуюцца бібліятэк умее ltrace, зазірнуць у працу аперацыйнай сістэмы могуць SystemTap и ftrace, а глыбока даследаваць прадукцыйнасць праграм дазваляе перф. Тым не менш менавіта strace - Першая лінія абароны ў выпадку праблем з уласнымі і чужымі праграмамі, і выкарыстоўваю я яе мінімум пару разоў на тыдзень.
Словам, кахаеце Unix, чытайце man 1 strace і не саромейцеся падглядваць за вашымі праграмамі!