0%

qemu tcg goto_tb分析

goto tb分析

qemu tcg跳转的处理中分析了qemu中模拟跳转的逻辑,其中提到了chained tb的概念,
chained tb中有一个限制,这个在它的代码实现里看出来:两个chained tb对应的guest
指令需要在同一个guest的page里。下面分析这样限制的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 VA1 ---> +----------+ 
| | map1
| | ----------+
VA2 ---> +----------+ map2 | +----------+ <--- PA1
| | ------+ +---> | |
| | map3 | | |
+----------+ ----+ | +----------+ <--- PA2
| | | |
| +-------> | |
| +----------+ <--- PA3
| | |
+---------> | |
+----------+
tb1 key: pa1
+-------+ <---------------------+ <--- htable
| | |
+-------+ |
| |
v tb2 key: pa2 |
+-------+ <-------------+ |
| | | |
+-------+ | |
| |
tb_jmp_cache key: va | |
+----------------------------------------+
| va2 va1 |
+----------------------------------------+

qemu在翻译执行的时候,对于tb有两个cache,首先所有生成的tb都会放到htable这个哈希表,
这个是我们这里说的第一个cache,这个哈希表的key是guest代码块的起始pa以及一些其它的
flag,另外一个cache叫tb_jmp_cache,这个是很简单的用guest代码块的起始va做key(这个
cache和本文分析的问题无关,对于它的分析,我们单独放到最后)。

htable用pa以及flag做key,在翻译执行的时候首先会用va查tlb得到pa,如果tlb里没有缓存,
那就触发page table walk或者取指令异常,终归是要把pa找到并填到tlb里,因为是物理地址
做key,所以有可能不同虚拟地址映射到相同物理地址上时,对应的tb也只有一个。

当程序运行时,一段代码的位置,从va看是不变的,但是代码的物理存储位置可能是变化的,
比如上图中从map2到map3映射的改变。从htable的设计上看,这种映射关系改变和tb之间并
没有直接的联系,因为改变的只是va到pa的映射,而tb在htable上是用pa做key,退一步讲,
如果多个不同的va映射到一个pa上,其中一个映射改变了,其它的映射还在,对应的tb应该
继续保留。

如上的情况是va映射不同pa上的代码是相同的情况,最常见的是页换出换入的情况。如果chain
tb跨越了这两个虚拟页,chain tb在语意上已经不对了,因为改变映射关系后,被指向的tb
应该是以PA3页面上的PA作为key的,但是实际上因为改变映射的两个物理页上的指令是一样
的,运行可能不会出问题。

但是,如果改变映射前后的物理页上的指令不一样,chain tb跨越不同页就会出错,当然
这种情况很少会有实际的应用场景。

chained tb还需要面对的一个问题是,如果被指向的tb对应的guest指令被修改了怎么办,
比如,上面tb2对应的guest指令被修改了,之前得到tb2显然是不能用了。qemu使用了一个叫
PageDesc的软件结构管理guest的物理内存,管理的单位是guest的页大小,一个PageDesc
对应一个guest页,PageDesc记录着相关guest页对应的所有tb,理论上看,如果一个guest
页上的指令有修改,qemu只要找到修改的guest指令对应的tb块,把这个tb块从chained tb
的链条里移除就好。

这里具体上是靠tb里的jmp_reset_offset、jmp_list_head、jmp_list_next以及jmp_desc
域段实现的,这个几个域段的语意分别是,jmp_list_head是指向tb的tb组成的链表,jmp_desc
是本tb的两个可能的直接跳转的地址,jmp_list_next用于把tb链入jmp_list_head链表,因为
一个tb可能指向两个tb,一个tb可能被链入两个jmp_list_head,所以jmp_list_next数组有
两个元素,jmp_reset_offset是本tb两个可能的直接跳转地址的复位值。

在两个tb相连的时候更新如上的信息,jmp_reset_offset在后端翻译里计算和更新。

1
2
3
4
5
6
7
8
9
10
tb_add_jump(tb, n, tb_next
/* 把跳转地址记录在jmp_dest[n] */
+-> qatomic_cmpxchg(&tb->jmp_dest[n], (uintptr_t)NULL, (uintptr_t)tb_next)

/* 更新host指令中的跳转地址 */
+-> tb_set_jmp_target(tb, n, (uintptr_t)tb_next->tc.ptr)

/* 把指向tb_next的tb插入tb_next里的jmp_list_head链表 */
+-> tb->jmp_list_next[n] = tb_next->jmp_list_head
+-> tb_next->jmp_list_head = (uintptr_t)tb | n

chained tb之间的关系可以用如下的图来描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+----------------+   +----------------+   +----------------+
|tb0 | |tb1 | |tb2 |
| | | | | |
|jmp_list_next[0]|<--|jmp_list_next[0]|<+ |jmp_list_next[0]|<-------------+
|jmp_list_next[1]| |jmp_list_next[1]| +-|jmp_list_next[1]|<-----------+ |
+----------------+ +----------------+ +----------------+ | |
\ | / \ | |
\ | / \ 0 | |
0 \ 0 | / 1 v | |
\ | / +----------------+ | |
v v v |tb4 | | |
+----------------+ | | | |
|tb3 | |... | | |
| | |jmp_list_head --+--+-+
|... | +----------------+ |
|jmp_list_head --+---------------------------------+
+----------------+

从如上数据结构中去掉一个tb的逻辑如下, 比如我们这里要去掉tb3,具体的逻辑是:

1
2
3
4
5
6
7
8
9
10
do_tb_phys_invalidate
/*
* 找到tb3指向的节点,把tb3指向的tb中的jmp_list_head上的tb3节点去掉, 分别
* 对tb3可能指向的两个tb节点做这样的操作。
*/
+-> tb_remove_from_jmp_list(tb, 0)
+-> tb_remove_from_jmp_list(tb, 1)

/* 从tb3的jmp_list_head的到指向他的tb, 更新这些tb的指向 */
+-> tb_jmp_unlink(tb)

如上已经介绍了goto tb相关的关键逻辑,下面进一步看下qemu PageDesc相关的整体实现。
PageDesc初始化的地方是page_init函数,可以看出相关的位置是各种tcg资源初始化的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* accel/tcg/tcg-all.c */
tcg_init_machine
+-> page_init
/*
* 这个函数里初始化PageDesc的各级表项的配置,它和页表类似的用一个多级表
* 管理整个物理地址空间。
*
* 对于riscv 44bit的物理地址空间来说,如果一个PageDesc管理4KB的页面,
* 整个物理地址空间的管理分为两级,第二级一项管理1K个页面,那么第一级有
* 44 - 12 - 10 = 22,2 ^ 22项。
*/
+-> page_table_config_init
+-> tb_htable_init
/* tcg后端翻译需要的资源 */
+-> tcg_init

通过物理地址找见PageDesc的函数:

1
page_find_alloc

把一个tb加入到一个PageDesc里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
tb_page_add 
/*
* 把tb插入first_tb指向的链表的第一个节点,n是1表示有指令跨域了一个page,
* 那么就要在两个page对应的PageDesc中都要做记录。
*
* 实际上在riscv的qemu tcg模型里,只有一种跨page边界的情况,我们把这个逻辑
* 独立放到下面。
*/
+-> tb->page_next[n] = p->first_tb
+-> p->first_tb = tb | n
/*
* 如果是第一次给这个PageDesc加tb,就是这个page上的指令第一次翻译出tb块,
* 把对应page的dirty bit清理(初始化)下。
*
* 如下的dirty bit和qemu里虚拟机的热迁移有关系。
*/
+-> tlb_protect_code(page_addr)
/* softmmu/physmem.c */
+-> cpu_physical_memory_test_and_clear_dirty

从PageDesc里去掉一个tb,直接从链表里去掉对应的tb即可:

1
tb_page_remove(p, tb)

PageDesc里tb被去掉的调用路径:(to do test)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
store_helper
+--> notdirty_write(env_cpu(env), addr, size, full, retaddr);
+-> tb_invalidate_phys_page_fast(pages, ram_addr, size, retaddr);
+-> tb_invalidate_phys_page_range__locked
+-> tb_phys_invalidate__locked(tb);
/* 如上讲的guest代码被修改后,需要移除对应的tb,相关逻辑的入口是这里*/
+-> do_tb_phys_invalidate(tb, true);
/* 移除htable里的tb */
+-> qht_remove(&tb_ctx.htable, tb, h)
/* 移除PageDesc里的tb */
+-> tb_page_remove
/* 从tb链条里移除tb */
+-> tb_remove_from_jmp_list(tb, 0);
+-> tb_remove_from_jmp_list(tb, 1);
+-> tb_jmp_unlink(tb);

我们可以把gdb的断点设在tb_invalidate_phys_page_range__locked上,然后运行一段自修改
代码(自修改代码可以参考这里),看看实际运行的效果。

指令垮页分析

实际上,在riscv 7.1.50的qemu tcg模拟中,只有一个4B指令跨越页边界这一种情况存在了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
riscv_tr_translate_insn
[...]
/* Only the first insn within a TB is allowed to cross a page boundary. */
if (ctx->base.is_jmp == DISAS_NEXT) {
/*
* pc_first pc_next
* | |
* | v | v |
* +-----------+-----------+
* | Page | Page |
*
*/
if (!is_same_page(&ctx->base, ctx->base.pc_next)) {
ctx->base.is_jmp = DISAS_TOO_MANY;
} else {
unsigned page_ofs = ctx->base.pc_next & ~TARGET_PAGE_MASK;
/*
* pc_first pc_next
* | |
* | v v | |
* +-----------+-----------+
* | Page | Page |
*
* 这里就是pc_next为页尾 - 2Byte的情况,这种情况下,如果pc_next指向
* 的指令是一个2B的压缩编码指令,最后这个指令还可以放到当前tb,如果是
* 一个4B的普通指令,那么pc_next + len就到了下一个页里,需要结束当前
* tb。
*
* 对于下个4B编码的指令,下次翻译先取16bit,得到是一个32bit指令后再
* 再把后16bit取出来,前端翻译后走到上面第一个分支里,发现已经跨越了
* 页边界,于是停止当前tb的翻译,这个tb里只翻译了一个跨越页边界的32bit
* 指令。
*/
if (page_ofs > TARGET_PAGE_SIZE - MAX_INSN_LEN) {
uint16_t next_insn = cpu_lduw_code(env, ctx->base.pc_next);
int len = insn_len(next_insn);
if (!is_same_page(&ctx->base, ctx->base.pc_next + len)) {
ctx->base.is_jmp = DISAS_TOO_MANY;
}
}
}
}

tb_jmp_cache分析

tb_jmp_cache用va做key是存在问题的,一个系统里可能有相同的va映射到不同的pa,比如,
两个进程的va相同,映射的pa就可能不同。所以,要保证在tb_jmp_cache里,va只唯一映射
一个pa,这个在user mode似乎没有问题,在system mode的完整逻辑还有待分析。(todo)