编辑
2025-05-21
记录知识
0

最近在调试程序内存的问题时,通过asan挂上内存监听,遇到了一个stack-use-after-scope的错误,asan调试其他问题的介绍后面有空再介绍,本文通过asan发现了C++一个关于生命周期的bug,它会导致程序错误的使用栈。问题比较经典,故特定分享一下。

问题现象

在使用 播放器播放视频的时候,如果不断的移动播放器进度条,有非常小的概率导致移动失败。
再加上调试过程中,移动进度条播放时,相关代码存在内存泄漏,故相应代码部位添加了asan用于检测。

调试和日志

开启asan的方式简单介绍如下

QMAKE_CXXFLAGS += -fsanitize=address -g QMAKE_LFLAGS += -fsanitize=address

然后挂上asan的库运行即可

LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libasan.so.5.0.0 ./kylin-video

其他问题我们先不关注,这里只关注本文章讨论的stack-use-after-scope,故asan检测到的错如下

==88163==ERROR: AddressSanitizer: stack-use-after-scope on address 0x007ffe54b9b0 at pc 0x007f99a74504 bp 0x007ffe54ab40 sp 0x007ffe54abb8 READ of size 3 at 0x007ffe54b9b0 thread T0 #0 0x7f99a74500 (/usr/lib/aarch64-linux-gnu/libasan.so.5.0.0+0x64500) #1 0x7f9753d408 in bstr0 ../misc/bstr.h:61 #2 0x7f9753d408 in set_node_arg ../input/cmd.c:179 #3 0x7f9753dfd8 in cmd_node_array ../input/cmd.c:227 #4 0x7f9753dfd8 in mp_input_parse_cmd_node ../input/cmd.c:301 #5 0x7f9753e638 in mp_input_parse_cmd_strv ../input/cmd.c:502 #6 0x7f9755f2ec in mpv_command_async ../player/client.c:1196 #7 0x557793118c in MpvCore::Seek(int, bool, bool) core/mpvcore.cpp:588 #8 0x5577932a74 in operator() core/mpvcore.cpp:1330 #9 0x5577932a74 in call /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:146 #10 0x5577932a74 in call<QtPrivate::List<int>, void> /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:256 #11 0x5577932a74 in impl /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:439 #12 0x5577932a74 in impl /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:432 #13 0x7f95bd79e4 in QMetaObject::activate(QObject*, int, int, void**) (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x29c9e4) #14 0x5577b02500 in GlobalUserSignal::sigSeek(int) .moc/moc_globalsignal.cpp:1047 #15 0x55779e1174 in GlobalUserSignal::seek(int) global/globalsignal.h:47 #16 0x55779e1174 in operator() widget/contralbar.cpp:647 #17 0x55779e1174 in call /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:146 #18 0x55779e1174 in call<QtPrivate::List<>, void> /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:256 #19 0x55779e1174 in impl /usr/include/aarch64-linux-gnu/qt5/QtCore/qobjectdefs_impl.h:439 #20 0x7f95be52b8 (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x2aa2b8) #21 0x7f95bd8264 in QObject::event(QEvent*) (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x29d264) #22 0x7f967468e8 in QApplicationPrivate::notify_helper(QObject*, QEvent*) (/lib/aarch64-linux-gnu/libQt5Widgets.so.5+0x15e8e8) #23 0x7f9674feec in QApplication::notify(QObject*, QEvent*) (/lib/aarch64-linux-gnu/libQt5Widgets.so.5+0x167eec) #24 0x7f95ba89c0 in QCoreApplication::notifyInternal2(QObject*, QEvent*) (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x26d9c0) #25 0x7f95c051ac in QTimerInfoList::activateTimers() (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x2ca1ac) #26 0x7f95c05adc (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x2caadc) #27 0x7f96d82708 in g_main_context_dispatch (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x51708) #28 0x7f96d82974 (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x51974) #29 0x7f96d82a18 in g_main_context_iteration (/lib/aarch64-linux-gnu/libglib-2.0.so.0+0x51a18) #30 0x7f95c05e70 in QEventDispatcherGlib::processEvents(QFlags<QEventLoop::ProcessEventsFlag>) (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x2cae70) #31 0x7f95ba716c in QEventLoop::exec(QFlags<QEventLoop::ProcessEventsFlag>) (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x26c16c) #32 0x7f95baf770 in QCoreApplication::exec() (/lib/aarch64-linux-gnu/libQt5Core.so.5+0x274770) #33 0x55778f3dc0 in main src/main.cpp:68 #34 0x7f954ced8c in __libc_start_main (/lib/aarch64-linux-gnu/libc.so.6+0x20d8c) #35 0x55778ff55c (/home/kylin/kylin-video+0xdf55c) Address 0x007ffe54b9b0 is located in stack of thread T0 at offset 928 in frame #0 0x557793040c in MpvCore::Seek(int, bool, bool) core/mpvcore.cpp:552 This frame has 28 object(s): [48, 49) '<unknown>' [64, 65) '<unknown>' [80, 81) '<unknown>' [96, 97) '<unknown>' [112, 113) '<unknown>' [128, 129) '<unknown>' [144, 145) '<unknown>' [160, 161) '<unknown>' [176, 184) '<unknown>' [208, 216) '<unknown>' [240, 248) '<unknown>' [272, 280) 'tmp' (line 565) [304, 312) '<unknown>' [336, 344) '<unknown>' [368, 376) '<unknown>' [400, 408) '<unknown>' [432, 440) '<unknown>' [464, 472) '<unknown>' [496, 504) '__dnew' [528, 536) '<unknown>' [560, 568) '__dnew' [592, 624) '<unknown>' [656, 680) 'args' (line 573) [720, 752) 'args' (line 568) [784, 816) '<unknown>' [848, 880) 'args' (line 587) [912, 944) '<unknown>' <== Memory access at offset 928 is inside this variable [976, 1016) 'args' (line 581) HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork (longjmp and C++ exceptions *are* supported) SUMMARY: AddressSanitizer: stack-use-after-scope (/usr/lib/aarch64-linux-gnu/libasan.so.5.0.0+0x64500) Shadow bytes around the buggy address: 0x001fffca96e0: f8 f2 00 00 00 f2 00 00 00 f2 00 00 00 f2 00 00 0x001fffca96f0: 00 f2 00 00 00 f2 00 00 f8 f2 00 00 00 f2 00 00 0x001fffca9700: 00 f2 00 00 f8 f2 00 00 f8 f2 f2 f2 f8 f8 f8 f8 0x001fffca9710: f2 f2 f2 f2 00 00 00 f2 f2 f2 f2 f2 00 00 00 00 0x001fffca9720: f2 f2 f2 f2 00 00 00 00 f2 f2 f2 f2 00 00 00 00 =>0x001fffca9730: f2 f2 f2 f2 f8 f8[f8]f8 f2 f2 f2 f2 00 00 00 00 0x001fffca9740: 00 f3 f3 f3 f3 f3 00 00 00 00 00 00 00 00 00 00 0x001fffca9750: 00 00 00 00 00 00 f1 f1 f1 f1 00 f3 f3 f3 00 00 0x001fffca9760: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001fffca9770: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001fffca9780: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 Shadow byte legend (one shadow byte represents 8 application bytes): Addressable: 00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb Shadow gap: cc ==88163==ABORTING

关于asan的一下解释后面更新,这里通过错误我们看到了stack-use-after-scope,这个什么意思呢?如下

  • stack-use-after-scope 指的是在栈区变量生命周期之外使用到了栈区变量

通俗点就是,访问了一个作用域之外的栈变量。

此时,通过反汇编代码,可以看到真正存在问题的汇编如下

0x0000007ff74133f8 <+536>: ldr x23, [x23]

可以看到这是一个取值操作,根据汇编上下文和代码上下文分析,其代码如下

int r = m_option_parse(log, opt, bstr0(cmd->name), bstr0(val->u.string), dst);

对于取值,我们可以看到val->u.string,随机可以通过gdb打印相关值,如下

(gdb) p val->u.string $6 = 0x7fffffe5b8 "32"

可以看到,这里ldr就是把0x7fffffe5b8里面的值32取走了,符合bstr0(val->u.string)的逻辑。我们看看程序的栈区范围如下

kylin@kylin:~$ cat /proc/198018/maps | grep stack 7ffffdf000-8000000000 rw-p 00000000 00:00 0 [stack]

可以看到0x7fffffe5b8就是栈区的变量。
我们知道问题是播放器点击进度条的时候概率出现的,所以我们回到播放器的代码分析

void MpvCore::Seek(int pos, bool relative, bool osd) const char *args[] = {"seek", QString::number(pos).toStdString().c_str(), "absolute", NULL}; mpv_command_async(m_mpvHandle, MPV_REPLY_COMMAND, args); }

这里32的字符串的值就是QString::number(pos).toStdString().c_str()传递进去的。
我们还需要留意一个细节,那就是mpv_command_async,稍微跟踪一下代码如下

mpv_command_async run_async_cmd run_async mp_dispatch_enqueue

到这里我们可以清楚了,mpv的控制是异步接口,默认的命令通过队列管理,异步触发。

分析问题

根据上面的代码分析,调试和日志,我们知道了这是在一个异步请求上出现的访问了作用域之外的栈变量。
也就是说,args的构造中使用了栈变量,它标记销毁了,所以我们重点看args是如何构造的

const char *args[] = {"seek", QString::number(pos).toStdString().c_str(), "absolute", NULL};

我们可以知道,"seek" "absolute" 都是只读数据区,那么唯一出现在0x7fffffe5b8位置,也就是栈区的值就是QString::number(pos).toStdString().c_str()。 那么我们可以得出结论

  • QString::number(pos).toStdString().c_str() 返回的值,在其作用域之外会被回收
  • 代码是异步的,Seek函数调用完毕之后,栈区会回收局部变量

为了解决这个问题,我们就要从c++关于prvalue(pure rvalue:纯右值),也就是临时变量的生命周期谈起,相关文章如下,有兴趣的可以直接翻阅

https://en.cppreference.com/w/cpp/language/lifetime

理论支持

根据上面文献的描述,我们在如下情况下可以延长生命周期

  • binding a reference to a prvalue
  • initializing an object of type std::initializer_list< T > from a brace-enclosed initializer list (since C++11)
  • returning a prvalue from a function
  • conversion that creates a prvalue (including T(a, b, c) and T{})
  • lambda expression (since C++11)
  • copy-initialization that requires conversion of the initializer,
  • reference-initialization to a different but convertible type or to a bitfield. (until C++17)
  • when performing member access on a class prvalue
  • when performing an array-to-pointer conversion or subscripting on an array prvalue
  • for unevaluated operands in sizeof and typeid
  • when a prvalue appears as a discarded-value expression

根据上面的描述,可以选用如下两种来解决此问题

  1. binding a reference to a prvalue
  2. copy-initialization that requires conversion of the initializer

其他的并不符合当前代码上下文状态。

给纯右值做引用绑定

std::string&& str = QString::number(pos).toStdString(); const char *args[] = {"seek", str.c_str(), "absolute", NULL};

这里 && 是使用右值引用,它等效于const情况下的左值引用,如下

const std::string& str = QString::number(pos).toStdString(); const char *args[] = {"seek", str.c_str(), "absolute", NULL};

这样可以实现对c_str()这个栈区的变量进行延长生命周期

拷贝初始化

std::string str = QString::number(pos).toStdString(); const char *args[] = {"seek", str.c_str(), "absolute", NULL};

这里直接初始化了一个str,对于args的传递,我们使用初始化完成的str来访问c_str(),这样的方式延长了其生命周期。

总结

至此,我们通过分析了c++关于临时变量延长生命周期的方式,解决了一个概率很低的bug(隐秘的bug),此bug一般不会出现问题,但长时间运行和压力测试就会出现错误,通过前期的分析和借助asan工具,可以很好的地位此问题。
另一方面,此问题是关于c++的string类型的生命周期问题,c_str()是当时栈区的临时变量,QString::number(pos).toStdString()如果和c_str()一起写,也就是QString::number(pos).toStdString().c_str(),那么这个string作为临时变量,它的生命周期本应该在;结束,所以c_str()指向的栈区会在作用域之外失效,为了延长其生命周期,我们通过右值引用和带const的左值引用,以及完全初始化string变量都可以延长其生命周期。

所谓延长其生命周期,也就是使得string有效访问,直到函数传递后不再访问string类型变量为止,由c++定义回收。否则,其string类型变量使用之后,内容再回收,异步的程序如果再次访问此栈区,可能会在某种情况下,被应用程序某个行为进行修改,从而导致此值的错误,然后从隐秘的bug变成实际出现的bug。

编辑
2025-05-14
记录知识
0

在工作中,通常我们身上挂着几十上百个bug,对应着十多个设备机器,如果每次都是人走过去调试非常不方便。如果是非界面的问题,我们可以通过ssh远程,如果是界面问题,我们只能通过vnc远程连接,但是vnc远程不是很方便,本文介绍一个更常用和方便的方法,novnc。基于此加速我们调试问题的节奏

安装

安装主要是安装Vino和novnc,默认机器可能已经预装了,对于没有预装的版本,可以如下

apt install novnc vino websockify

当然,如不想使用vino,那么tightvncserver也可以,但是本人更常用vino,本文介绍也通过vino

apt install tightvncserver

配置

对于vnc,我们需要默认打开一下配置,我们可以在控制面板里面选,那太麻烦了,人还是得过去一趟
所以下面介绍直接配置

ssh kylin@localhost gsettings set org.ukui.control-center vino-enabled true gsettings set org.gnome.Vino prompt-enabled false vncpasswd systemctl --user restart vino-server

上面配置就是打开了vnc的配置,这样远程可以通过vnc viewer进行连接,例如如下

image.png

接下来配置novnc就很简单了

kylin@kylin:~$ /usr/share/novnc/utils/launch.sh Using installed websockify at /usr/bin/websockify Starting webserver and WebSockets proxy on port 6080 WebSocket server settings: - Listen on :6080 - Web server. Web root: /usr/share/novnc - No SSL/TLS support (no cert file) - proxying from :6080 to localhost:5900

然后到自己编程的机器上,打开浏览器,登录网站http://${ip}:6080/vnc.html 就出现如下画面

image.png

点击连接,输入密码

image.png

浏览器正常登录vnc

image.png

多个机器就是多个浏览器网页页面而已。

总结

这里介绍了我平时多个bug同时调试的一种方法,用来提高自己解决问题效率。

编辑
2025-05-13
记录知识
0

mmu是arm64芯片上的一个内存管理单元,使用mmu的机制可以快速通过硬件将虚拟地址转换成物理地址,本文介绍crash工具的使用,可以比较方便解析内核的结构,故根据虚拟地址向物理地址转换的过程来梳理掌握crash工具的使用,并复习一下内存的基本知识。

机器环境

对于arm64机器的页表设置,每个机器的环境不一致,但是原理都是一致的,本文机器环境如下

  • 39位地址
  • 4k页大小
  • 9bit页表项
  • 三级页表
CONFIG_ARM64_VA_BITS_39=y CONFIG_ARM64_4K_PAGES=y CONFIG_PGTABLE_LEVELS=3

为了找到符合我机器的说明,我翻了很早的内核版本docs,可以参考信息如下

AArch64 Linux memory layout with 4KB pages + 3 levels: Start End Size Use ----------------------------------------------------------------------- 0000000000000000 0000007fffffffff 512GB user ffffff8000000000 ffffffffffffffff 512GB kernel Translation table lookup with 4KB pages: +--------+--------+--------+--------+--------+--------+--------+--------+ |63 56|55 48|47 40|39 32|31 24|23 16|15 8|7 0| +--------+--------+--------+--------+--------+--------+--------+--------+ | | | | | | | | | | | v | | | | | [11:0] in-page offset | | | | +-> [20:12] L3 index | | | +-----------> [29:21] L2 index | | +---------------------> [38:30] L1 index | +-------------------------------> [47:39] L0 index +-------------------------------------------------> [63] TTBR0/1

根据上面可以知道,当前环境最大内存大小是512GB,因为是三级页表,所以这里默认没有L0 index [47:39]。为了概念的统一,我这里将三级页表也叫做Level 1和Level 2和Level 3以及 in-page offset,同四级页表一个叫法。
关于页表的转换,手册默认是按照四级页表计算,如下图示

image.png

我们根据三级页表计算,下面开始演示。

以内核地址为例

为了拿到一个已经映射的地址,我们可以加载一个ko,用其中的数据地址,或者直接使用某个进程的task_struct内存地址,本文获取的是test.ko启动kthread的task_struct

5190 2 5 ffffff80c0002b80 RU 0.0 0 0 [spinlock_thread]

可以看到此task_struct地址是ffffff80c0002b80,下面开始转换

虚拟地址解析

对于上面的虚拟地址,我们按位可以解析如下

image.png

可以得到pgd_offset=3,pmd_offset=0,pte_offset=0xb80。并且我们知道其结构体大小如下

crash> struct pgd_t -x typedef struct { pgdval_t pgd; } pgd_t; SIZE: 0x8 crash> struct pud_t -x typedef struct { p4d_t p4d; } pud_t; SIZE: 0x8 crash> struct pmd_t -x typedef struct { pmdval_t pmd; } pmd_t; SIZE: 0x8 crash> struct pte_t -x typedef struct { pteval_t pte; } pte_t; SIZE: 0x8

可以看到,因为我们是64位系统,存放一个页表的size就是8,而我们页表换算的时候,是按照基地址+offset计算的,这个offset值的是多少个地址,而不是地址的size,所以我们获得基地址之后,计算下一级页表的存放物理地址的时候,应该乘上size,也就是8,因为实际存放这个64位地址要占用8个字节,那么这里我们就可以知道信息如下

  • level1页表的offset是3,size就是0x18
  • level2页表的offset是0,size就是0
  • level3页表的offset是2,size就是0x10
  • in-page offset 就是0xb80

TTBR1_EL1

我们获取的是内核的地址,那么其基地址来源于TTBR1_EL1,对于内核,其默认值存放在变量init_mm.pgd中,我们可以查看如下

crash> p init_mm.pgd $1 = (pgd_t *) 0xffffffc009eea000

我们可以根据这个基地址开始计算pgd

计算pgd

pgd的地址就是TTBR的地址 加上 pgd_offset * 8 ,所以得出如下

0xffffffc009eea018 = 0xffffffc009eea000 + 0x3 * 8

此时读取其值就是下一级页表的基地址

crash> rd ffffffc009eea018 -x ffffffc009eea018: 00000001ff20f003

值得注意的是,虚拟地址只是操作系统抽象的东西,所以存放的下一级页表的基地址是物理地址,不是虚拟地址,我们拿到地址 0x00000001ff20f003
对于arm64而言,内存不是恒等映射的,而是线性映射,也就是我们拿到的物理地址,其虚拟地址通常是 加上 ffffff8000000000 的offset 的线性映射的,所以我们拿到的地址0x00000001ff20f003 的虚拟地址是

0xffffff81ff20f003 = 0x00000001ff20f003 + 0xffffff8000000000

我们验证一下线性映射地址即可,如下

crash> ptov 0x00000001ff20f003 VIRTUAL PHYSICAL ffffff81ff20f003 1ff20f003

这里虚拟地址的bit [0:1] 用来表示是Block/Page,这里是3,也就是Page,我们寻找下一级页表。

计算pmd

pmd的地址是 对应的虚拟地址 去掉 bit0和bit1 后 加上 offset,这么说可能有点绕,转换计算公式如下

0xffffff81ff20f000 = 0xffffff81ff20f003 & ~0x3 + 0x0 * 8

然后读取其值即可获得下一级页表基地址的物理地址

crash> rd ffffff81ff20f000 -x ffffff81ff20f000: 00000001ff20e003

同样的,这里通过命令转换,当然也可以自己手动加上0xffffff8000000000的线性映射的offset。

crash> ptov 1ff20e003 VIRTUAL PHYSICAL ffffff81ff20e003 1ff20e003

这里看到还是3,说明不是Block,而是Page。

计算pte

pmd的地址计算和pud一致,如下

0xffffff81ff20e010 = 0xffffff81ff20e003 & ~0x3 + 0x2 * 8

此时读取下一级页表

crash> rd ffffff81ff20e010 -x ffffff81ff20e010: 00680000c0002707

这里我们接下来读的是pte的地址,0x00680000c0002707 具备upper attr和lower attr。关于页属性的描述如下

image.png

对于pte地址,其值是0x00680000c0002707,那么其属性值如下表示

image.png

除了属性值,其他部分就是物理页面基地址了,我们需要去掉属性值,提取物理页面基地址,那么如下

680000c0002707 & ~0xfff = 680000c0002000 # 去掉低12位 680000c0002000 & 0x7fffffffff = c0002000 # 去掉高25位

物理页

通过上面的pte地址,去掉属性之后,我们得到了物理页也就是0xc0002000,我们知道page-in offset是0xb80,那么真实的页就是

0xc0002b80 = 0xc0002000 + 0xb80

这里就计算出来了虚拟地址对于的物理页地址了。
同样的,在crash中,默认提供的vtop会自动帮我们计算,vtop的结果如下

crash> vtop ffffff80c0002b80 VIRTUAL PHYSICAL ffffff80c0002b80 c0002b80 PAGE DIRECTORY: ffffffc009eea000 PGD: ffffffc009eea018 => 1ff20f003 PMD: ffffff81ff20f000 => 1ff20e003 PTE: ffffff81ff20e010 => 680000c0002707 PAGE: c0002000 PTE PHYSICAL FLAGS 680000c0002707 c0002000 (VALID|SHARED|AF|PXN|UXN) PAGE PHYSICAL MAPPING INDEX CNT FLAGS ffffffff02e00080 c0002000 dead000000000400 0 0 0

可以看到,我们手动计算的地址转换过程和vtop提供的信息一样。

以用户地址为例

上面已经介绍过转换过程了,下面用户地址为例的转换就快速过一下。做个验证

测试代码

为了测试,提供一个简单的代码

#include <stdio.h> #include <unistd.h> char str[] = "hello kylin"; int main() { printf("vtop -c %d %p [%s] \n", getpid(), str, str); while(1); return 0; }

输出结果

我们拿到信息如下

vtop -c 13725 0x411038 [hello kylin] crash> vtop -c 13725 0x411038 VIRTUAL PHYSICAL 411038 8c2c3038 PAGE DIRECTORY: ffffff81f262b000 PGD: ffffff81f262b000 => 1f2c5a003 PMD: ffffff81f2c5a010 => 1f5fd0003 PTE: ffffff81f5fd0088 => e800008c2c3f43 PAGE: 8c2c3000 PTE PHYSICAL FLAGS e800008c2c3f43 8c2c3000 (VALID|USER|SHARED|AF|NG|PXN|UXN|DIRTY) VMA START END FLAGS FILE ffffff805b21a840 411000 412000 100873 /root/test PAGE PHYSICAL MAPPING INDEX CNT FLAGS ffffffff0210b0c0 8c2c3000 ffffff806d097b29 1 1 80014 uptodate,lru,swapbacked

此时知道了物理地址是0x8c2c3038,读取内容即可

crash> rd -8 -p 8c2c3038 16 8c2c3038: 68 65 6c 6c 6f 20 6b 79 6c 69 6e 00 00 00 00 00 hello kylin.....

一切正常,下面开始手动推导

手动推导页表转换

TTBR0_EL0

我们先获取基地址,对于进程,基地址在task_struct的struct_mm的pgd

crash> set 13725 PID: 13725 COMMAND: "test" TASK: ffffff81f0a09d00 [THREAD_INFO: ffffff81f0a09d00] CPU: 7 STATE: TASK_RUNNING (ACTIVE)

这里我们拿到了task_struct的地址是ffffff81f0a09d00,那么mm的值是0xffffff81f5fa2ec0

struct task_struct { [1160] struct mm_struct *mm; } crash> struct task_struct.mm ffffff81f0a09d00 -o struct task_struct { [ffffff81f0a0a188] struct mm_struct *mm; } crash> struct task_struct.mm ffffff81f0a09d00 -x mm = 0xffffff81f5fa2ec0

此时我们获得了进程的pgd地址如下

crash> struct mm_struct.pgd 0xffffff81f5fa2ec0 pgd = 0xffffff81f262b000

计算pgd

我们拿到地址是0xffffff81f262b000,此时虚拟地址是0x411038,计算如下

ffffff81f262b000 = ffffff81f262b000 + 0 crash> rd ffffff81f262b000 -x ffffff81f262b000: 00000001f2c5a003 crash> ptov 00000001f2c5a003 VIRTUAL PHYSICAL ffffff81f2c5a003 1f2c5a003

计算pmd

我们拿到地址0xffffff81f2c5a003,继续计算

ffffff81f2c5a010 = ffffff81f2c5a003 & ~0x3 + 0x2 * 8 crash> rd ffffff81f2c5a010 -x ffffff81f2c5a010: 00000001f5fd0003 crash> ptov 00000001f5fd0003 VIRTUAL PHYSICAL ffffff81f5fd0003 1f5fd0003

计算pte

我们拿到地址0xffffff81f5fd0003,最后计算pte

ffffff81f5fd0088 = ffffff81f5fd0003 & ~0x3 + 0x11 * 8 crash> rd ffffff81f5fd0088 -x ffffff81f5fd0088: 00e800008c2c3f43 crash> ptov 00e800008c2c3f43 VIRTUAL PHYSICAL e7ff808c2c3f43 e800008c2c3f43

计算物理地址

我们拿到了e800008c2c3f43的地址,需要去掉属性bit,计算可得

e800008c2c3f43 & ~0xfff = e800008c2c3000 # 去掉低12位 e800008c2c3f43 & 0x7fffffffff = 8c2c3000 # 去掉高25位

此时物理页起始是0x8c2c3000,对于数据的物理地址是

8c2c3038 = 8c2c3000 + 0x38

这样我们就计算出来物理页地址了,我们读取其内容验证一下

crash> rd -8 -p 8c2c3038 16 8c2c3038: 68 65 6c 6c 6f 20 6b 79 6c 69 6e 00 00 00 00 00 hello kylin.....

可以发现,其物理地址存放的内容正是我们设置的 "hello kylin"。 实验完成。

总结

根据上面的内容,我们通过物理地址和虚拟地址将页表的转换过了一遍,借助工具crash可以随时挂上内核的kcore进行调试判断,不会像gdb内核一样复杂并且不方便。

编辑
2025-05-12
记录知识
0

之前有简单介绍过abba锁,在了解crash的时候,发现其特别方便用于熟悉和调试内核,能够查看其实时的变量状态和出现堆栈后的状态,本文基于锁来简单演示通过crash查看锁状态,从而提供调试锁的一种方法

测试代码

在<ABBA锁介绍>的文章中提供了测试代码,复用即可

spinlock测试

在测试之前,我们查看spinlock结构体如下

crash> struct qspinlock -o -x struct qspinlock { union { [0x0] atomic_t val; struct { [0x0] u8 locked; [0x1] u8 pending; }; struct { [0x0] u16 locked_pending; [0x2] u16 tail; }; }; } SIZE: 0x4

读取变量的信息如下

crash> rd -8 spinlock_a 4 ffffffc001542030: 00 00 00 00 .... crash> rd -8 spinlock_b 4 ffffffc001542018: 00 00 00 00 ....

我们开启spinlock测试

echo 1 > /sys/module/test/parameters/testsuite

此时我们可以观察到spinlock_a/b变量的值如下

crash> rd -8 spinlock_a 4 ffffffc001542030: 01 01 00 00 .... crash> rd -8 spinlock_b 4 ffffffc001542018: 01 01 00 00 ....

上面数据需要注意大小端,我们可以直接解析如下

crash> struct qspinlock spinlock_a -x struct qspinlock { { val = { counter = 0x101 }, { locked = 0x1, pending = 0x1 }, { locked_pending = 0x101, tail = 0x0 } } } crash> struct qspinlock spinlock_b -x struct qspinlock { { val = { counter = 0x101 }, { locked = 0x1, pending = 0x1 }, { locked_pending = 0x101, tail = 0x0 } } }

可以看到,spinlock_a/b 的信息如下:

  • locked是1,代表锁被持有
  • pending是1,代表有一个任务尝试获取锁

可以看到,与代码现象ABBA锁相符

mutex测试

同样的,测试之前先读取数据结构

crash> struct mutex -o -x struct mutex { [0x0] atomic_long_t owner; [0x8] spinlock_t wait_lock; [0x20] struct optimistic_spin_queue osq; [0x28] struct list_head wait_list; } SIZE: 0x38

然后我们确定mutex的owner默认值是0

crash> struct mutex.owner mutex_a -x owner = { counter = 0x0 } crash> struct mutex.owner mutex_b -x owner = { counter = 0x0 }

开始测试

echo 2 > /sys/module/test/parameters/testsuite

此时我们看到mutex.owner变成了一个值,说明有人持有这个锁,如下

crash> struct mutex.owner mutex_a -x owner = { counter = 0xffffff804e286581 } crash> struct mutex.owner mutex_b -x owner = { counter = 0xffffff800dfd1d01 }

对于mutex,我们知道其flag如下

#define MUTEX_FLAG_WAITERS 0x01

其中含义: 是有任务正在等待锁
在这个flag之外,mutex的owner是一个task_struct结构体如下

static inline struct task_struct *__owner_task(unsigned long owner) { return (struct task_struct *)(owner & ~MUTEX_FLAGS); }

故我们计算出持有mutex_a的任务是

crash> struct task_struct.pid,comm 0xffffff804e286580 pid = 2818 comm = "spinlock_thread"

同样的持有mutex_b的任务是

crash> struct task_struct.pid,comm 0xffffff800dfd1d00 pid = 2819 comm = "spinlock_thread"

ps查看信息如下

crash> ps | grep 2819 2819 2 5 ffffff800dfd1d00 UN 0.0 0 0 [spinlock_thread] crash> ps | grep 2818 2818 2 4 ffffff804e286580 UN 0.0 0 0 [spinlock_thread]

这样我们就找到了谁在持有这个锁。

semaphore测试

先查看semaphore的结构体

crash> struct semaphore -o -x struct semaphore { [0x0] raw_spinlock_t lock; [0x18] unsigned int count; [0x20] struct list_head wait_list; } SIZE: 0x30

其初始值如下

crash> struct semaphore.count semaphore_a count = 1 crash> struct semaphore.count semaphore_b count = 1

此时我们开启测试

echo 3 > /sys/module/test/parameters/testsuite

semaphore的作用是down占用了资源,需要等待up恢复资源,默认情况下count是1,如果down了则是0,我们查看死锁后状态如下

crash> struct semaphore.count semaphore_b count = 0 crash> struct semaphore.count semaphore_a count = 0

可以看到semaphore变量a/b都是被占座了,所以产生了ABBA锁。

总结

本文主要是通过crash的方式来辅助定位死锁问题,在实际问题中,相当于多了一种方式排查问题,我们知道最有效排查死锁的问题还是内核提供的CONFIG,毋庸置疑。

编辑
2025-05-11
记录知识
0

我们在讨论linux的实时的时候,有时候会拿RTOS来做对比,根据最近对RTOS的理解,总结出来关于为什么linux实时不如RTOS的几个点,分享之

中断的不确定性

中断不能嵌套

在rtems中我们看的中断在特殊情况如内核态是支持中断嵌套的,但是linux是不允许中断嵌套。
也就是说,在Linux中,如何一个中断触发来,那么必须等到同CPU的上一个中断处理完成。
这时候,中断是不确定的

softirq

linux支持中断上下文(hardirq和softirq),我们知道softirq的优先级是高于kthread这类线程任务的(tasklet,timer,netrx等)

如果某个外设驱动的softirq一直占用某个core,那么整个core上的thread很可能都得不到运行

我处理can的报文接收(netrx)的时候就遇到过这类情况,它直接导致来一个cpu core不工作。

那么这也就是另一个不确定性,也就是softirq是外设驱动代码,由客户自己编写的,如果softirq执行时间不确定,那么中断就是不确定的 。

spinlock影响高优先级任务的不确定性

例如某个cpu core上,有一个普通任务正在spin lock,那么core上的任务必须等spin unlock后,才能得以调度。

那么此时其任务就是不确定的。因为谁也没办法知道要等多久才能unlock。这样高优先级就不知道什么时候才能得以运行了。

内存的lazy模式导致进程不确定性

在linux中内存是使用的时候才分配的,高优先级任务在使用内存的时候,内存分配过程只能保证你分配到,但是不能保证你在确定时间内申请到。 甚至可能没有内存导致oom出现。

PREEMPT_RT的作用

基于上面提到的,PREEMPT_RT主要解决如下三个问题

  1. 中断可以被抢占,嵌套
  2. 中断线程化
  3. softirq可以被抢占
  4. mutex替换spin_lock

对于中断风暴,做法是在线程内disable irq,线程化完成之后,自动enable irq

而softirq线程化,做法是全部将其添加到ksoftirqd(tasklet,timer,netrx等)执行

spinlock的做法就是将raw_spin_lock 替换成 rt_mutex (可睡眠的,支持优先级继承的mutex,避免优先级翻转)

PREEMPT_RT的问题

根据上面就知道

  • 对于中断,普通中断响应更差了
  • 对于softirq,相比于原来运行路径更长了,且容易被常规任务抢占
  • 对于锁,可睡眠就会导致进程切换,多次进程切换代价太大,性能远不如spin_lock

所以可以得出结论,添加PREEMPT_RT的内核,它可以保证某个高优先级任务的一定的实时性,但其负面作用会导致其他所有常规任务性能低下。 所以只适合于定制的特殊场景。

如何优化特殊场景下要求的实时任务呢

根据上面提到的,使用PREEMPT-RT的内核,一定程度上保持了高优先级任务的实时性,那么基于此,我们还需要做哪些事情来定制操作系统,满足这个实时任务的确定性要求呢?

DEADLINE调度

内核其实提供了EDF调度,也就是SCHED_DEADLINE。我们让这个实时任务在这个特殊的调度类中。

chrt工具动态设置进程的调度政策,如chrt --deadline / --fifo
当然pthread_setschedparam也可以设置调度器种类,如SCHED_DEADLINE/SCHED_FIFO传参

isolate和taskset

通过bootargs传参隔离某个cpu,然后通过taskset将实时任务绑定到这个cpu上跑

timer

如果要高实时,建议直接汇编使用寄存器调用arm64的高精度定时器来封装特定代码。

优先级继承

对于多线程的实时任务,pthread线程创建前建议设置优先级继承,避免优先级翻转。 也就是pthread_muterattr_setprotocol中传参PTHREAD_PRIO_INHERIT。 其默认是NONE。

占住堆

默认从内核申请的内存都可能被交换,那么申请的内存调用mlock锁住即可,这样这块内存不会被换出。同时,带mlock标志位的内存会在申请的时候,自动触发缺页异常,分配物理页面。相当于lazy机制的逃逸了。

占住栈

堆和栈都是内存,对于堆好办,我们直接mlock即可,对于栈,这是操作系统分配的,我们设置了最大栈大小之后,例如是8M,如果是线程,那么可以设置pthread_attr_setstacksize

在所有函数调用之前,调用一个函数,然后通过局部变量申请一个8M,或者你设置的栈大小的空间,将其占住,然后进行memset一次调用。此时栈会通过缺页异常申请内存,后续就不会在缺页异常申请了。 文字表现力不强,如下是代码

void stack_prefetch() { const size_t size = 8 * 1024 * 1024 - 4096; char buffer[size]; memset(buffer, 0, sizeof(buffer)); }

避免数据伪共享

跑在多核的程序,需要利用好cache一致性,例如局部性原理,但是这里要说的是避免数据伪共享,所以多线程访问同一个结构体时,最好cacheline对齐

__attribute__((aligned(cachesize)))

其他

其他就是性能优化相关的了,通过性能优化可以保证任务的低时延。

总结

本文初衷是介绍一下Linux实时内核,并说明Linux实时并不能硬实时的原因,但是为了表述清楚,也补充了使用PREEMPT-RT补丁的内核,和实时应用程序的编写建议,这些建议纯粹属于经验分享性质,不代表权威信息。

总之,PREEMPT-RT是基于linux内核上,为了保证某个实时任务的确定性做的优化,它需要配合该实时任务的很多其他措施一起使用,否则体现不了实时的确定性。 也就是说我们讨论实时任务的时候,应该讨论整个链路的实时性,而不是某一个方面的实时确定性,那没有任何意义

并且,PREEMPT-RT是牺牲整体系统性能为代价而提供的,使用时需要认清利弊。

PREEMPT-RT做不到完全的硬实时确定性,所以如果面临产品级的开发,推荐使用Linux + RTOS 双OS的策略,RTOS本身关注确定性任务,Linux本身关注复杂计算。