Jul 7

没用的小技巧:在运行时标记定位代码调用来源 (iOS)

Lrdcq , 2019/07/07 16:46 , 程序 , 閱讀(220) , Via 本站原創
偶尔由于一些很奇怪,很没道理,很hack的理由,我们的程序需要在运行时知道这个函数是从哪儿,或者是谁调用的,并且针对这个情况做一些特殊处理。作为iOS开发,我们的程序的方法实现都是货真价实的机器码,在尽量不破坏程序源码甚至闭源的情况下,我们能做到这个事儿吗?有个小技巧可以做:

核心工具

实现这个事情的核心工具是__builtin_return_address(level)方法(https://gcc.gnu.org/onlinedocs/gcc/Return-Address.html)。

__builtin_return_address是gcc的一个标准拓展工具,iOS开发当然可以用这玩意儿。这个方法可以打出当前函数(子程序)的调用来源返回时执行的指令地址,即调用当前方法后的pc指针位置。并且通过level参数可以逐步向前。
void main() {
  caller();
  caller();
  caller();
}

void caller {
  void *stock0 = __builtin_return_address(0);
}

以上这一段代码caller就会分别获渠道main的这三行子程序调用即ARM中的BL指令后调用的pc地址。

因为核心问题就是,我怎么标记一个函数的调用代码的地址呢?

基于label直接获取

如果我们要通过编码的方式获取某一行代码的地址,怎么做呢?最方便的方式是label。

label就是本来用来做goto语句的那个mark,不过在gcc的拓展中(https://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html),定义了&&操作可以直接获取label标记的位置所在的地址。可以这样使用:
//somewhere
foo:...;
//somewhere
void *ptr;
/* … */
ptr = &&foo;
//somewhere
goto *ptr;

我理解本来Labels as Values这个设计是用来解决label的作用域在函数内,goto无法跨函数调用的。不过通过这正好获取到label标记的地址了,所以结合__builtin_return_address,判断代码调用来源的代码可以这样写:
static IMP fromA, fromB, fromC;

void mainX() {
    fromA = &&labelA;
    fromB = &&labelB;
    fromC = &&labelC;
    caller();labelA:;
    caller();labelB:;
    caller();labelC:;
}

void caller() {
    IMP stock0 = __builtin_return_address(0);
    if (stock0 == fromA) {
        NSLog(@"fromA");
    } else if (stock0 == fromB) {
        NSLog(@"fromB");
    } else if (stock0 == fromC) {
        NSLog(@"fromC");
    }
}

其中几个小技巧:

1. &&label获取到的代码地址格式无所谓啦,一般声明为void*就好,不过在oc代码中,标记为IMP在xcode断点调试过程中会有更友好的展示。
點擊在新視窗中瀏覽此圖片

2. 目前写法其实label标记的调用代码下一行开始的地址,因为__builtin_return_address实际获取的上一条指令结束后接下来应该执行的地址,所以这样真好可以对上。另外可以尝试直接fromA()调用也可以执行,不过和执行goto效果一样,出入参堆栈会错乱,如果没保护的话会直接炸。

基于label标记区间

如果我一个方法非常复杂,并且有相当多的地方需要知道是调用来源于这个方法的,这个方法可以直接尝试输出方法开始和结束的位置:
static IMP kMainXStart, kMainXEnd;

void mainX() {
    kMainXStart = &&mainXStart;
    kMainXEnd = &&mainXEnd;
    mainXStart:;
    /*
     * many thing
     */
    mainXEnd:;
}

这样外部使用者直接去判断或者递归的判断就好了。

不过注意到,其实kMainXStart的标记并没有必要,因为一个函数开始的位置我们有正常的方法获取。
- 对于C函数来说,函数指针指向的位置就是函数开始的指令位置。
- 对于oc方法来说,方法开始的位置就是IMP函数指针的位置,通过runtime获取IMP大家当然都会呀。

基于函数指针标记区间

通过上面的方法,注意到,其实如果我们已知道代码源文件中有哪些方法,我们通过一个函数逻辑代码前后的函数指针就可以大概定位到一个方法的来源,即:
void mainX(){
}
void mainY(){
}
void mainZ(){
}
//判断
IMP stock0 = __builtin_return_address(0);
if (&mainX < stock0 && stock0 < &mainY) {
    NSLog(@"fromA");
}

这里利用到的特性是对于同一个源文件,正常的编译器编译出来的子程序顺序和源文件一致。(当然用了二进制重排之内的优化技术就炸了。)

对于OC来说,情况类似,需要注意是oc中全局block就和普通函数没区别,而内联block会编译在声明的方法后,即:
- (void)mainX {
    ...^{
        call();
    }
}

- (void)mainY {
    ...^{
        call();
    }
}

会编译为sub_mainX,sub_mainX_block1,sub_mainY,sub_mainY_block1。因此,正好通过IMP的mainX和mainY判断block是否在某个方法类,其实是可行的。

闭源的情况

同时,以上代码注意到都是可以看到或者修改源码的情况下我们的处理方法,如果是闭源的情况,我们只能看到二进制码,我们怎么判断呢?这个其实更简单啦:

- 因为指令调用的相对位置已经是完全确定的了,因此直接看汇编/反编译确定调用位置后判断的地方直接硬编码结果就是可行的。

- 对于oc来说,反编译也很容器看见目标方法上下是什么东西,因为是已编译文件,因此就算跨类跨模块也不怕。直接用runtime拿IMP直接判断就好。

当然。本记录讲的所有方法,都不建议真的在工程中使用。
关键词:ios , oc
logo