一、背景
在之前的 线程局部存储tls的原理和使用_linux tls存放在堆区-CSDN博客 博客里,我们讲到了glibc提供的tls机制及tls的原理及与内核的配合逻辑。在之前的 非gdb方式观察应用程序的运行时的变量状态-CSDN博客 博客里,我们讲到了如何非侵入式观测进程里的某个变量的方法。这篇博客,我们把两个课题结合,来用非侵入式观测的方法来查看一个进程里的某个线程的tls数据。
我们在第二章里先贴出源码并给出运行效果,在第三章里,我们对相关代码和步骤的原理进行分析。
二、源码及运行效果
需要说明的是,本文观测的tls变量只涉及编译出的程序的源码里直接使用的tls变量,并不涉及观测程序所依赖的so库里使用到的tls变量。
2.1 用户态的tls例程
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <unistd.h>
thread_local int threadStatus; // 每个线程的局部状态变量
void threadFunction(int threadID) {
threadStatus = threadID;
std::cout << threadID << ": gettid = " << gettid() << std::endl;
while (true) {
threadStatus += 100; // 增加状态值
//std::cout << "Thread " << threadID << ": Status = " << threadStatus << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1)); // 暂停1秒
}
}
int main() {
const int numThreads = 10;
std::vector<std::thread> threads;
// 创建并启动线程
for (int i = 0; i < numThreads; ++i) {
threads.emplace_back(threadFunction, i);
}
// 等待线程结束(在这个例子中,线程将永远运行,因此我们不会在这里join线程)
for (auto& thread : threads) {
thread.join();
}
return 0;
}
2.2 用于观测tls变量的内核模块源码
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/mm.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
MODULE_LICENSE("GPL");
static int watchpid = 0;
module_param(watchpid, int, 0);
//static unsigned long va = 0; // 要观测的另外一个进程里的虚拟地址
static unsigned long fsbaseoffset = 0;
module_param(fsbaseoffset, ulong, 0);
static int __init my_module_init(void) {
struct task_struct *task;
struct mm_struct *mm;
struct page **pages;
int nr_pages = 1;
long offset;
char *data;
struct vm_area_struct *vma;
int ret = 0;
unsigned long watch_va;
unsigned long fsbase;
unsigned long va;
pid_t pid = watchpid;
task = pid_task(find_vpid(pid), PIDTYPE_PID);
if (!task) {
printk(KERN_ERR "Task not found\n");
return -1;
}
mm = task->mm;
if (!mm) {
printk(KERN_ERR "No memory map\n");
return -1;
}
data = kzalloc(4096, GFP_KERNEL);
if (!data) {
printk(KERN_ERR "Memory allocation failed\n");
return -1;
}
if (fsbaseoffset == 0) {
printk(KERN_ERR "fsbaseoffset no input!\n");
return -1;
}
fsbase = task->thread.fsbase;
va = fsbase - fsbaseoffset;
vma = find_vma(mm, va);
if (!vma)
return -EFAULT;
if (!(vma->vm_flags & VM_MAYEXEC))
return -EACCES;
pages = kmalloc_array(nr_pages, sizeof(struct page*), GFP_KERNEL);
offset = (va - (va & PAGE_MASK));
ret = pin_user_pages_remote(mm, va & PAGE_MASK, nr_pages, FOLL_FORCE, pages, NULL);
if (ret < 0) {
printk(KERN_ERR "Failed to pin_user_pages_remote, ret[%d]\n", ret);
goto label_free;
}
else {
ret = 0;
}
watch_va = page_address(pages[0]);
if (!watch_va) {
printk(KERN_ERR "Fail to page_address\n");
ret = -1;
goto label_free;
}
memcpy(data, watch_va + offset, 4);
printk(KERN_INFO "Data: %d\n", *((int*)data));
unpin_user_page(pages[0]);
label_free:
kvfree(data);
kvfree(pages);
return ret;
}
static void __exit my_module_exit(void) {
printk(KERN_INFO "Module exited\n");
}
module_init(my_module_init);
module_exit(my_module_exit);
2.3 运行效果
为了方便验证,我们用vs2019的ssh远程gdb方式断点在某个线程,看到即时的变量值状态,方便和用内核模块获取到的数值做对比验证。
2.3.1 使用vs2019的ssh远程gdb方式断点在某个线程,确定要观察的线程的tid和对应的tls变量的数值
(有关如何使用vs2019的ssh远程gdb方式调试linux程序,见之前的博客 vs2019进行远程linux用户态调试_vs2019远程调试linux-CSDN博客)
先运行起来 2.1 里的用户态的tls例程的程序,然后用断点断在下截图处,我们看到这时候线程6的tid是1001463,线程6的这时候的threadStatus变量是106:
2.3.2 通过objdump -t 程序 | grep tls的变量,来得到tls变量相较于线程局部存储基址的偏移
再通过objdump -t 程序 | grep tls的变量,来得到tls变量相较于线程局部存储基址的偏移:
2.3.3 通过内核模块获取用户态tls例程程序里某个线程的tls变量的数值,传入线程的tid和刚拿到的要观测的tls变量相对于线程局部存储基址的偏移
通过内核模块获取用户态tls例程程序里某个线程的tls变量的数值,传入线程的tid和刚拿到的要观测的tls变量相对于线程局部存储基址的偏移:
(tid用 2.3.1 里断点下来的那个线程1001463,该线程如 2.3.1 看到该线程上的threadStatus的即时值是106)
insmod progtlswatch.ko watchpid=1001463 fsbaseoffset=4
目前的实现获取到的数值是打印到printk里的:
如下图的printk的打印,确实得到了106:
三、原理分析
如第二章里描述的方法,在运行了程序之后,先执行objdump -t 程序 | grep tls的变量名,来得到tls变量相较于线程局部存储基址的偏移,然后再通过内核模块获取到线程局部存储基址并计算出要观测的变量在要观测进程上下文下的虚拟地址,最后在通过内核模块获取该进程上下文的虚拟地址上的值。
我们按照这个步骤一一进行分析。在进行这些步骤的分析之前,我们先对使用tls变量的用户态例程做简要说明。
3.1 用户态例程使用了C++的tls变量修饰声明的方式
例程里使用了thread_local这个C++的修饰符来表示变量是tls变量:
使用thread_local修饰的变量,它可以是POD变量,也可以是一个类结构,如果线程里使用到了该tls变量,那么它对应的构造函数是在其线程里的第一次使用时触发,其析构函数是在线程退出时触发,如果线程里没有使用到该变量,它对应的构造函数和析构函数都不会执行。
关于tls机制的原理及tls的详细的使用及注意事项见之前的博客 线程局部存储tls的原理和使用_linux tls存放在堆区-CSDN博客。
3.2 objdump -t 程序 | grep tls的变量名,得到tls变量相较于线程局部存储基址的偏移
3.2.1 grep出来的内容里两个数字相加,就是tls变量相较于线程局部存储基址的负偏移
我们的程序名字叫tlssample.out,程序里用的tls变量名是threadStatus,grep出来的情况如下:
我们把上图里红色框出的两个数字相加,就是4,4就是例程的tls变量相较于线程局部存储基址的偏移。
3.2.2 把例程里的tls变量的使用改得相对复杂一些,来确定tls变量相较于线程局部存储基址的偏移的计算逻辑
为了更加确定上面的格式的实际含义,我们把例程里的thread_local变量增多,定义的地方前面和后面各加两个int大小的tls变量,另外再把grep的tls变量名对应的size增大,增大到8字节:
修改过后的程序完整源码如下:
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <unistd.h>
thread_local int aaa;
thread_local int bbb;
struct teststatus {
int a;
int b;
int c;
int d;
int e;
};
thread_local teststatus threadStatus = { 0 }; // 每个线程的局部状态变量
thread_local int ccc;
thread_local int ddd;
void threadFunction(int threadID) {
threadStatus.a = threadID;
std::cout << threadID << ": gettid = " << gettid() << std::endl;
while (true) {
threadStatus.a += 100; // 增加状态值
//std::cout << "Thread " << threadID << ": Status = " << threadStatus << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1)); // 暂停1秒
}
}
int main() {
const int numThreads = 10;
std::vector<std::thread> threads;
// 创建并启动线程
for (int i = 0; i < numThreads; ++i) {
threads.emplace_back(threadFunction, i);
}
// 等待线程结束(在这个例子中,线程将永远运行,因此我们不会在这里join线程)
for (auto& thread : threads) {
thread.join();
}
return 0;
}
如上图这样的修改过后,再进行objdump -t 程序 | grep tls的变量名,如下图:
看上图的.tbss后面的数值是0x14,是20字节,正好对应于struct teststatus的大小。
而第一个数字0x8加上上面说的.tbss后面的数值0x14,和是0x1c,则对应于线程局部存储基址的负偏移的绝对值,如下图里展示的反汇编的那句汇编的数值:
如上面的实验也可以得到另外一个thread_local的使用上的细节,就是对于我们在程序里并没有使用的thread_local变量,程序在编译时也会给它腾出空间。与之有关的另外一个细节需要再强调一下,就是虽然腾出了空间,但是不使用thread_local声明的变量,它就不会触发thread_local变量的构造函数(如果thread_local变量有构造函数的话),关于这个细节的说明见之前的博客 线程局部存储tls的原理和使用_linux tls存放在堆区-CSDN博客 里的 2.2.1 一节,再拓展一下,用C方式__thread声明tls变量,并调用过pthread_setspecific的线程,无论是否真正使用它,都是会触发注册的清理函数回调的,细节见 线程局部存储tls的原理和使用_linux tls存放在堆区-CSDN博客 里的 2.1.1 一节。
3.3 内核模块根据线程的tid获取到线程的局部存储基址
当前编写的内核模块是用的比较粗暴和直接的方式,当前基于的实验平台是x86_64平台,也确定打开了X86_FEATURE_FSGSBASE,关于该特性是否打开的验证在之前的博客 线程局部存储tls的原理和使用_linux tls存放在堆区-CSDN博客 的 3.3.1 一节里有做过验证:
所以有关的获取fsbase的实现里的部分可以直接挪到内核模块里作为获取线程局部存储基址的方式。
内核里的实现:
挪到内核模块里简单粗暴的获取:
3.4 计算出要观测的tls变量在要观测的进程上下文下的虚拟地址
有关的逻辑如下:
上图里的fsbaseoffset是通过insmod传参传入的:
3.5 得到要观测的tls变量的数值
上面的 3.4 这一步已经拿到了要观测的tls变量在要观测的进程上下文下的虚拟地址,接下来我们只需要用之前的博客 非gdb方式观察应用程序的运行时的变量状态-CSDN博客 里一样的方法,来拿到这个地址上的值。
核心函数是pin_user_pages_remote函数,逻辑如下:
有关pin_user_pages_remote的相关函数get_user_pages和pin_user_pages的详细说明见之前的博客 内存管理之——get_user_pages和pin_user_pages及缺页异常_get user page-CSDN博客。