Termux and atexit in Android
Termux 的作者 @fornwall**,在2017/4/16發了一個 **Issue #933**, 說在使用 **
libgpg-error
時遇到一些問題。
Intro to termux-pacakges
之前 有提過 Termux 是個在 Android 底下的終端模擬器,而附上了 Termux
套件系統,可以用 apt
來管理安裝。
termux-packages 就是 Termux 編譯出這些套件的地方,裡面利用 Docker
來建構交叉編譯的環境。個別套件遇到的問題,就都會在 termux-packages 發 issues
討論。
Issue #933 in termux-packages
Termux 的作者 @fornwall**,在2017/4/16發了一個 **Issue #933**,
說在使用 **libgpg-error
時遇到一些問題。
Issue #933 裡面附上的 Sample Code
1 |
|
用下列命令安裝執行
1 | apt install -y clang libgpg-error-dev && clang test.c -lgpg-error && ./a.out |
理論上應該會要看到 A
顯示出來才對。但是只有在某些裝置才會顯示
- Working (output produced): aarch64 (tested on Android 7.0 and 7.1 devices)
- Not working (no output produced): 32-bit arm (tested on an Android 6.0)
Patches needed for gnupg2
因為這個問題,所以沒有辦法只好在用到 libgpg-error
的程式,其實就是
gnupg2
**,裡面加上了一些 **patches。雖然可以暫時解掉,
但還是希望能夠有正解的分析,才能安心地從 gnupg1
轉換到 **gnupg2
**。
Difference between aarch64 and arm ?
會對這個 Issue 引起興趣是因為 @fornwall 猜測是 arch 的不同引起的差異。 個人之前在 Termux 上遇到問題的時候,也是很容易就會猜測是 arch (e.g. x86_64) 不同造成的,有機會會另外寫一篇講這個,後來詳細追了下去 發覺其實都是 Android 本身系統問題,很難是 arch 造成的原因。
The Condition: Android Ver.
很快地把 Sample Code 編譯起來在自己的幾台機器跑了一下,發覺其實都印不出
**A
**,哈哈哈。
因為那時候手上還沒有 Andoird 7.x 的手機啊XD
沒關係! Android SDK 有出 Emulator 可以跑起來測試,為此還多抓了 Android 7.1 的 arm 的影像檔,可以跑起來但是慢慢的就是。
順便該一下,現在要開新的 avd 都不能直接用 **
android
**了,很麻煩耶。
所以才趕緊搶隻便宜手機,趕緊刷到 Android 7.1 的嘛!(誤)
測試結果看起來是 Android 版本的問題,而不是 arch 造成的。
- Working: i686 (tested on Android 7.1 emulator)
- Not working: aarch64 (tested on an Android 6.0 device)
好的,釐清了會發生問題的條件後,就來研究一下 libgpg-error
如何處理
es_putc
**,簡單看過之後,大致上就是有個 **estream 自行實現了
buffered 字串流功能,會等到 buffer 滿了之後再一塊刷出到 fd,
像是 stdout 或是 stderr。
那要是主程式程式要離開了,但 buffer 裡面還有東西該怎麼辦呢? 這時候會需要有個收尾的 function 來把還沒刷出去的給印出來, 不然就會覺得被吃掉了。
這個收尾的 function 就叫作
do_deinit()
**,是透過 **libc 的atexit()
註冊的。
Debug atexit with abort()
馬上就來在 do_deinit()
前面很趕緊加個 fprintf(stderr)
**,
看看能不能印出東西來代表有沒有跑到。結果是不行的,沒有看到訊息被印出來,
所以直覺地認為在有問題的手機上 **do_deinit
是沒有被跑到的。
那就來看看會跑到的手機,是怎麼進去這個收尾 do_deinit()
呢?
加個 abort()
在剛剛的 fprintf(stderr)
之後吧,
這樣就可以得到 core dump 了,有這個就可以 **bt
**看是誰叫到了。
Termux 很讚是,已經有 gdb
可以裝了,接著有疑問的地方是剛剛程式不是用
clang
編譯的嗎?這樣也可以用 gdb
嗎?其實是可以的啦。
結果有問題的手機,也是會
abort
的,這表示都有跑到do_deinit()
No stdin, stdout and stderr
看起來是 do_deinit()
有被叫到,但是 fprintf
沒有辦法印出東西來,
這太有趣了!什麼時候會遇到 printf
沒有辦法正常工作呢?
printf
可以說是 debug 的第一步啊XD
既然有 gdb
了,那來中斷在 do_deinit()
的進入點上,看看這時候
/proc/PID/fd/ 有哪裡不一樣吧。
1 | $ gdb ./issue933 |
stdin, stdout, stderr 在這時候已經被清掉了啊啊啊啊XDDD
Test Code: termux-so-atexit
為了更加簡單釐清問題,寫了一個很基本的測試程式 termux-so-atexit 來驗證會造成 stdout 消失的原因。**termux-so-atexit** 會儘量把環境一步步逼近跟 Issue #933 一樣, 這樣就可以知道是什麼部份差異所造成的。
只要有裝好 Termux 可以很容易安裝以及跑這個測試程式。
1 | $ apt install hub |
attribute ((constructor))
經過了 termux-so-atexit 的逼近,確定只要符合底下的條件, 就會發生 stdout 消失的問題。
- 使用
Android 7.0
以前的版本 atexit()
是在被宣告有__attribute__ ((__constructor__))
的函數裡呼叫的
libgpg-error
就是這樣,它會判斷編譯器是不是支援
__attribute__ ((__constructor__))
1 | /* gpg-error.h */ |
而 gpg_err_init()
最後會叫到 **_gpgrt_estream_init()
**,
終於在這裡註冊了 **atexit
**。
1 | /* estream.c */ |
Workaround PR #1017
知道是這個 __attribute__ ((__constructor__))
造成的,那要怎麼避開這個問題呢?
首先先來看看要是平台不支援這種 constructor 的話,
libgpg-error
會如何處理。
1 | /* gpg-error.h */ |
個人認為這邊是寫錯的,gpgrt_init() 的定義應該反過來才對啊?!
好吧,將錯就錯!反正現在即使有 __attribute__ ((__constructor__))
也還是會再進去 _gpgrt_estream_init()
一次就是。
那麼就改成可以再註冊一次
atexit(do_deinit)
就可以了!(神奇吧!哈哈!)
底下就是送出去給 termux-packages 的 Pull Request PR #1017
1 | { |
Executed Order of atexit()
為什麼這樣修正 (PR #1017) 就可以正常印出字串到 stdout 了呢?根據
atexit()
的 manual 所描述
Functions so registered are called in the
reverse order
of their registration;
簡單地說就是個 Stack 或是 First In Last Out 的架構。
所以進入 main() 後再被註冊的 do_deinit()
會先被執行到,
而這時候 stdout, stderr 等的 fd 還在,就可以正常刷出還在 buffer
裡的字串到螢幕上了。
PR #1017 很快就被 merged 了,即使還是個 workaround, 但跟原本的比起來,至少在一處做比多個用到 libgpg-error 的地方改還乾淨一點。
Root Cause: bionic
有沒有發現,上面講了這麼多,其實真正的原因還沒找到耶!(驚)
After exit()
好吧,所以來找找看關於 atexit()
的部份是不是有相關的變動,
不然怎麼會新的 Android 7.x 就沒問題了呢?那麼來找找看 Bionic
原始碼在程式離開之後是怎麼叫到被 atexit()
註冊的 functions。
1 | libc/stdlib/exit.c |
1 | libc/stdlib/atexit.c |
喔!抓到了,看起來 __libc_stdio_cleanup()
就很像是關掉 stdout 的人啊!
本來以為是這樣,但其實 並不是喔! 因為它只做 fflush()
而已啊啊啊!!
1 | libc/stdio/stdio.c |
malloc_fini_impl, where stdout been closed
好吧,只好再繼續找找看誰會 fclose(stdout)
**,然後又可能會在樓上的
**__cxa_finalize()
裡被叫到。
1 | libc/bionic/malloc_common.cpp |
啊原來是 BSD 實作本來就不會關掉 stdout,是為了 debug malloc leaks,
會檢查有還沒關掉的 fd,所以才用 **malloc_fini_impl()
**,把它們關掉的。
這實在太有趣了XD
gdb test to look at functions registered with atexit()
所以到底為什麼 Android 7 Nougat 就沒有問題, Android 6 Marshmallow就會
GG 咧?對啦,真的就是上面的 malloc_fini_impl()
有沒有被叫到的差異。
可以利用 gdb
以及 termux-so-atexit 來檢查看看喔。稍微改一下,讓
so_atexit
可以被跑到兩次。
1 | # On Not-working Devices |
由以上的 gdb
結果可以知道:
malloc_fini_impl()
有被執行到,而且夾在兩次so_atexit()
的中間- 第二次的
so_atexit()
就看不到 stdout 了
相同的
gdb
測試步驟,在 Android 7 的手機上, 是不會停在malloc_fini_impl()
的
Difference before main()
最後來分別看一下 Android 7 與 Android 6 的 Bionic 原始碼, 以驗證上面所有看到的現象是合理的。
Malloc debug is must on Android Marshmallow
1 | libc/bionic/libc_init_dynamic.cpp |
1 | libc/bionic/libc_init_common.cpp |
1 | libc/bionic/malloc_debug_common.cpp |
在 Android 6 時,是把 libc_fini()
註冊到 atexit
中,
而當程式離開的時候,會從這裡一路呼叫到 malloc_debug_fini()
**。
到這裡竟然會用 **pthread_once()
叫一次 malloc_fini_impl()
**,
也就是剛剛說的會把 **stdout 等等關掉的 function。
再稍微看一下 libc_fini()
的 comments ,就會發現到這一路的
atexit()
是發生在 dynamic linking 準備好之後了。
把之前的例子 termux-so-atexit 註冊 atexit()
的時序圖畫一下,
大概是這樣子:
- __constructor__") --> __libc_fini("__libc_fini") __libc_fini("__libc_fini
- fclose(stdout)") -->|"main()"| soatexit1("so_atexit
- after main()") end
如果還記得最前面有說到, atexit()
有說執行的時候,
是要按造註冊的順序反過來的,所以如上圖的順序,就會了解到原本一開始用
__constructor__
註冊的 so_atexit()
為什麼會看到 stdout
等等不見的情形了。
Malloc debug is optional on Android Nougat
再看看看現在比較新一點的 Android 7 會呼叫到 malloc_fini_impl
的相關流程。
1 | libc/bionic/libc_init_dynamic.cpp |
1 | libc/bionic/malloc_common.cpp |
看起來在 Android 7 這是個 debug 選項,必須要用 setprop 設定一些參數,
才能讓 malloc_fini_impl
被註冊,不然預設是不會被叫到的,
也因為這樣才沒有遇到 Issue #933
完全就是個美麗的誤會,而不是 Bionic 發現到有這種問題而修正的XD
結論是不要在
atexit
的 function 中假設 stdout 還在啊啊啊!!