Google 支付从入门到跳坑

Google 支付真的不难,难的是由于“你懂的”的国内网络环境导致的复杂的测试流程和 Google Console “天花烂缀”的各种配置项目还有那些“文不达意”的报错信息。

很早写过一篇《Google Play In-app Billing 踩过的那些坑》,通过网站数据统计发现这篇文章的搜索和阅读量是最大的,后来又通过这篇文章的评论还有其他渠道和不少朋友们交流关于接入 Google 支付时遇到的问题发现大家对于这块儿还是有很多“心虚”的地方,还有一些“坑”我之前也没有说明白,所以我觉得有必要再写一篇来说说 Google 支付。

先吃两颗定心丸

有两件事一定要知道:

  • Google 支付很简单,一点儿都不难,所以不要头疼,不好害怕,不要压力山大。

  • 当你写好代码完成接入准备测试 Google 支付时,只要顺利弹出了 Google 支付相关的 UI 界面,哪怕是报错提示信息,千万不要怀疑你的代码,只要弹出 UI 界面,你的代码就是对的,问题出在配置上。

具体如何接入 Google 支付不是这篇文章的主旨,所以就不做详细的介绍了。因为 Google 支付接入真的非常简单。你可以仔细阅读《官方文档》或是我之前写的那篇《Google Play In-app Billing 踩过的那些坑》再或者干脆阅读 Android SDK 附带的 Google 支付的 Sample 示例工程的源代码都可以帮你快速轻松的完成 Google 支付的编码接入过程。

Samples 示例工程的位置在:

你的 Android SDK 目录/extras/google/play_billing/samples


Google Play Billing 示例程序位置


如果没有,请记得先打开 Android SDK Manager 去下载。


Android SDK Manager


配置并发布应用内商品

这里也是两点主意:

  • 配置完应用内商品一定要发布,使之生效

  • 如果测试的时候需要“翻墙”,诸如使用 VPN 时,一定要保证你的网络环境所对应的国家在发布范围内

第一条不用多说什么,在 Google Play Developer Console【应用内商品】中配置好商品,完成后的配置看起来是这个样子的就对了。


应用内商品发布


第二条一定要主意。一般在国内的开发者在测试 Google 支付的时候肯定是要“翻墙”的,这里就需要记住,举个例子,如果你使用的是美国的 VPN 进行测试,那么美国一定要在分发的国家或地区范围内,否则是无法进行测试的。


分发的国家或地区


上传 APK 并发布应用

这里需要注意的点比较多:

  • APK 包发布到 Beta 或者 Alpha 渠道即可,没必要发布到正式渠道。

  • 如果你的应用状态变为【已发布】说明发布成功了。

  • 一些隐私问题或政策问题会导致你的应用无法通过审核,使用第三方 SDK 或者权限时要多加小心。

  • 你安装到设备上用来测试的 APK 包可以和你上传到 Google Play Developer Console 上的 APK 包不同,但要保证这两个 APK 包使用了相同的签名,这两个 APK 包的 versionCode 要一致。

  • 你测试时使用的网络环境所属的国家和地区一定要在你应用发布的国家或地区范围内。

首先不要担心你的应用还没有开发完毕,大胆的发布你的应用。发布应用是测试 Google 支付的前提条件,所以请放心大胆的点击 Google Play Developer Console 页面右上角的【发布】按钮来发布你的应用吧。

要想发布你的应用你的上传你的 APK 包,这毋庸置疑。还是那句话,不要担心你的 APK 没有开发完或者还是个半成品,因为你在设备上安装用来测试的 APK 可以和你上传到 Google Play 的 APK 不一样。也就是说你可以先上传一个 APK 包,然后继续你的开发工作,任何开发或修改都不必重新上传你的 APK 包,直接将新生成的 APK 包安装到设备上测试即可。而你要做的就是保证安装到设备上的 APK 包的签名和上传的包的签名一致,AndroidManifest.xml 文件中 versionCode 也是一致的即可。

之前有个朋友公司的 APK 就遇到了因为接入了 TalkingData 的 SDK 导致违反了 Google 的隐私政策而无法通过审核的问题。所以这里也需要注意,关于 Google 对发布到 Google Play 的应用的政策要求请自行查阅相关文档。

最后一点其实在上面那个章节已经说过了,这里还要重申一下,一定要保证测试时使用的网络环境所在的国家和地区在你应用发布的国家或地区的范围内。这一点非常重要。

测试 Google 支付

到这里你距离成功就很近了:

  • 你的测试设备上一定要安装了 Google Play Service

  • 封闭测试时,除了要将测试人员的 Google Play 帐号加入封闭测试人员列表,还要让拥有这些测试帐号的人员通过访问生成的特殊链接来确认加入测试列表。

  • 测试支付是不会真的扣除你的任何费用的,但是即便如此你的测试 Google Play 帐号上还是需要绑定一张有国际支付能力的信用卡或银行卡的。

这里重点说说封闭测试,相对于开放性测试封闭测试的流程稍微复杂一些。首先如图所示:


封闭测试设置


首先要将想要参与测试的 Google Play 帐号加入到测试人员列表中。这样只有加入到列表中的 Google Play 帐号才能够测试 Google 支付。

这里最需要注意也是最容易被忽略的是在将测试人员的 Google Play 帐号加入到测试人员列表后,一定要记得将下面那个生成好的链接发给参与测试的人员,让他们用浏览器打开这个链接,只有这样测试人员才真的加入了测试列表,才可以真的进行 Google 支付测试。否则在进行支付测试时你将得到无法购买您要买的商品错误提示。

最后

只要避开上面这些“坑”,你会发现其实 Google 支付真的很简单。如果你不幸掉到其他的“坑”里面了,欢迎分享给我,我们一起填坑。


纯 Java 代码实现 Android UI

这是一篇很初级也很简单的教程。

为什么要用纯 Java 代码来实现 Android UI 界面

众所周知在 Android 开发时应用的 UI 界面一般是通过 XML 文件构建的。目前主流的 Android Studio 和 Eclipse 都可以通过鼠标拖拽控件的方式很高效的来搭建 UI 界面。那么为什么还要使用纯 Java 代码的方式来实现 UI 界面呢?

其实还是有一些特殊的场合需要使用这种纯 Java 代码的方式来实现 UI 界面的。例如 SDK 的开发。SDK 一般都是交付给第三方来使用的,要求接入流程尽可能简单,工作量尽可能少,最好直接一个 jar 包丢给对方,像这种情况纯 Java 代码来实现 UI 界面的方式就显得尤为重要了。

废话就到这,下面通过我工作中遇到的一个例子来展示一下如何用纯 Java 代码来实现 Android 的 UI 界面。

Android UI 界面需求

Android UI 界面需求

如上图所示,我们需要使用纯 Java 代码来实现这样一个 UI 界面,它的要求是:

  • 背景黑色半透明

  • 居中一个占整个屏幕 76% 的 ImageView 用于显示图片

  • 居中的 ImageView 要有一个白色的带圆角的边框

  • 居中的 ImageView 右上角还要有一个圆形的关闭按钮

  • 整个界面要求只能使用纯 Java 代码实现

  • 除了 ImageView 中显示的图片以外,不能使用其他图片素材

我们一步一步按照要求用纯 Java 代码来实现这个 UI 界面。

dp 与 px 的转换

至于 dipdppxsp这些概念就不在这里介绍了,大家自行搜索。这里要注意的是,在使用 XML 做 UI 界面布局时一般会使用 dp 做单位,但是在纯 Java 实现中方法参数都使用 px 做单位,所以这里就牵扯到 dppx 之间的转换。直接放出代码:

1
2
3
4
private int dp2px(float dp) {
    final float scale = getResources().getDisplayMetrics().density;
    return (int) (dp * scale + 0.5f);
}

整体布局

这里就不具体介绍 Android 布局的相关知识了,直接说怎么做。这里用了线性布局 LinearLayout,具体布局方式是

  • 先部署一个根布局 LinearLayout

  • 在根布局中从上到下嵌套三个横向 LinearLayout,其中头部和底部的 layout_weight=12 中间的 layout_weight=76

  • 在上一步嵌套的中间的 layout_weight=76 的布局中从左到右再嵌套三个纵向 LinearLayout,其中最左和最右的 layout_weight=12 中间的 layout_weight=76

这样我们就得到一块儿占屏幕 76% 居中放置的区域。对应的我们先展示出布局的 XML 形式:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?xml version="1.0" encoding="utf-8"?>
<!-- 根布局 -->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/rootLayout"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="1">

    <!-- 占 12% 高度的顶部 -->
    <LinearLayout
      android:id="@+id/topLayout"
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="0"
      android:layout_weight="12">
    </LinearLayout>

    <!-- 占 76% 高度的中间 -->
    <LinearLayout
      android:id="@+id/middleLayout"
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="0"
      android:layout_weight="76">
      
      <!-- 占 12% 宽度的左边 -->
      <LinearLayout
          android:id="@+id/leftLayout"
          android:orientation="vertical"
          android:layout_width="0"
          android:layout_height="match_parent"
          android:layout_weight="12">
      </LinearLayout>
      
      <!-- 占 76% 宽度的中间 -->
      <LinearLayout
          android:id="@+id/contentLayout"
          android:orientation="vertical"
          android:layout_width="0"
          android:layout_height="match_parent"
          android:layout_weight="76">
      </LinearLayout>
      
      <!-- 占 12% 宽度的右边 -->
      <LinearLayout
          android:id="@+id/rightLayout"
          android:orientation="vertical"
          android:layout_width="0"
          android:layout_height="match_parent"
          android:layout_weight="12">
      </LinearLayout>
      
    </LinearLayout>

    <!-- 占 12% 高度的底部 -->
    <LinearLayout
      android:id="@+id/bottomLayout"
      android:orientation="horizontal"
      android:layout_width="match_parent"
      android:layout_height="0"
      android:layout_weight="12">
    </LinearLayout>

</LinearLayout>

再用图片来示意一下:

整体布局示意图

然后我们按照这个整体布局将它翻译成纯 Java 代码的样式

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
    /* 根布局 */
    /* this 为当前的 Activity 实例 */
    LinearLayout rootLayout = new LinearLayout(this);
    LayoutParams rootLayoutParams = new LayoutParams(
        LayoutParams.MATCH_PARENT,
        LayoutParams.MATCH_PARENT
    );
    rootLayout.setOrientation(LinearLayout.VERTICAL);
    rootLayout.setLayoutParams(rootLayoutParams);

    /* 占 12% 高度的顶部 */
    LinearLayout topLayout = new LinearLayout(this);
    topLayout.setOrientation(LinearLayout.HORIZONTAL);
    LinearLayout.LayoutParams vMarginLayoutParams = new LinearLayout.LayoutParams(
        LayoutParams.MATCH_PARENT,
        0,
        12.0f
    );
    topLayout.setLayoutParams(vMarginLayoutParams);
    rootLayout.addView(topLayout);

    /* 占 76% 高度的中间 */
    LinearLayout middleLayout = new LinearLayout(this);
    middleLayout.setOrientation(LinearLayout.HORIZONTAL);
    middleLayout.setLayoutParams(new LinearLayout.LayoutParams(
        LayoutParams.MATCH_PARENT,
        0,
        76.0f
    ));
    rootLayout.addView(middleLayout);

    /* 占 12% 宽度的左边 */
    LinearLayout leftLayout = new LinearLayout(this);
    leftLayout.setOrientation(LinearLayout.VERTICAL);
    LinearLayout.LayoutParams hMarginLayoutParams = new LinearLayout.LayoutParams(
        0,
        LayoutParams.MATCH_PARENT,
        12.0f);
    leftLayout.setLayoutParams(hMarginLayoutParams);
    middleLayout.addView(leftLayout);

    /* 占 76% 宽度的中间 */
    LinearLayout contentLayout = new LinearLayout(this);
    contentLayout.setOrientation(LinearLayout.VERTICAL);
    contentLayout.setLayoutParams(new LinearLayout.LayoutParams(
        0,
        LayoutParams.MATCH_PARENT,
        76.0f
    ));
    middleLayout.addView(contentLayout);

    /* 占 12% 宽度的右边 */
    LinearLayout rightLayout = new LinearLayout(this);
    rightLayout.setOrientation(LinearLayout.VERTICAL);
    rightLayout.setLayoutParams(hMarginLayoutParams);
    middleLayout.addView(rightLayout);

    /* 占 12% 高度的底部 */
    LinearLayout buttomLayout = new LinearLayout(this);
    buttomLayout.setOrientation(LinearLayout.HORIZONTAL);
    buttomLayout.setLayoutParams(vMarginLayoutParams);
    rootLayout.addView(buttomLayout);

    this.addContentView(rootLayout, rootLayoutParams);

这里确实没有什么难点可讲,自己参照 XML 布局文件和对应翻译出来的 Java 代码感受一下就能明白了。

关闭按钮

关闭按钮的难点是首先它是圆形按钮,不是一般的方形,其次上面还要画一个叉子,而这一切不允许用图片素材只能用纯代码来实现。我们还是先用 XML 来实现一遍然后再对应翻译成 Java 代码再实现一遍。

先看看这个关闭按钮的 XML 布局文件。大体思路是先创建一个圆形蓝色的背景形状,然后在按钮中应用这个形状来做背景即可。

背景形状的 XML 文件要放在 res/drawable/ 目录下面,我们给起个名字 res/drawable/close_button_background.xml 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="false">
        <shape android:shape="oval">
            <solid android:color="#1B81C9"/>
        </shape>
    </item>
    <item android:state_pressed="true">
        <shape android:shape="oval">
            <solid android:color="#1B81C9"/>
        </shape>
    </item>
</selector>

然后在 res/layout/ 目录下的布局文件中定义关闭按钮,并使用上述的背景形状。

1
2
3
4
5
6
7
8
<Button
    android:id="@+id/closeButton"
    android:layout_width="24dp"
    android:layout_height="24dp"
    android:background="@drawable/close_button_background"
    android:text="X"
    android:textColor="#ffffff"
/>

上面的按钮布局代码只是一部分,具体关闭按钮的位置摆放方法我们放在后面来讲。用 XML 实现关闭按钮时并没有很好的处理上面白色的叉子的画法,当时只是“简单粗暴”的使用了按钮文字来解决,用了一个白色的大写 X 来作为关闭按钮上的叉子图形。不过接下来我们看到使用纯 Java 代码来实现时这里得到了完美的解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    /* 关闭按钮 */
    /* this 是 Activity 实例 */
    int closeButtonSizePX = dp2px(24);

    Button closeButton = new Button(this);
    RelativeLayout.LayoutParams closeButtonLayoutParams = new RelativeLayout.LayoutParams(
        closeButtonSizePX,
        closeButtonSizePX
    );
    closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
    closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
    closeButton.setLayoutParams(closeButtonLayoutParams);
    /* 按钮背景 */
    TGCPADCloseButtonBackground closeButtonBackground = new TGCPADCloseButtonBackground(this, closeButton);
    /* 圆形 */
    closeButtonBackground.setShape(GradientDrawable.OVAL);
    closeButtonBackground.setColor(Color.parseColor("#1B81C9"));
    closeButtonBackground.setStroke(12, Color.parseColor("#1B81C9"));
    closeButton.setBackground(closeButtonBackground);

上面代码中关于关闭按钮位置布局的部分可以先忽略,我们接下来会详细讲。这里我们把关注点放在按钮背景的实现上。这里关闭按钮的背景我们用了一个自定义的类,我们先看看这个自定义类的源码:

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
class TGCPADCloseButtonBackground extends GradientDrawable {

  private Context _context = null;
  private View _view = null;
  
  public TGCPADCloseButtonBackground(Context context, View view) {
      _context = (context);
      _view = view;
  }
  
  private int dp2px(float dp) {
      final float density = _context.getResources().getDisplayMetrics().density;
      return (int) (dp * density + 0.5f);
  }
  
  @Override
  public void draw(Canvas canvas) {
      super.draw(canvas);
      Paint paint = new Paint();
      int width = _view.getLayoutParams().width;
      int height = _view.getLayoutParams().height;
      int margin = dp2px(8);
      paint.setColor(Color.WHITE);
      paint.setStyle(Style.STROKE);
      paint.setStrokeWidth(dp2px(2));
      canvas.drawLine(margin, margin, width-margin, height-margin, paint);
      canvas.drawLine(margin, height-margin, width-margin, margin, paint);
  }
}

这个自定义类的代码并不多,它继承自 GradientDrawable 这个类,按照官方文档上的说明这个类是用来绘制按钮和背景的

A Drawable with a color gradient for buttons, backgrounds, etc.

所以这里的思路是让 TGCPADCloseButtonBackground 通过继承 GradientDrawable 来绘制一个蓝色的圆形按钮,然后再通过实现 draw 方法在已经绘制好的蓝色圆形按钮上面绘制一个白色的叉子。

图片视图 ImageView

这里的难点在于绘制一个带圆角的外边框。不过有了上面绘制关闭按钮的思路,这里的实现方式也大同小异了。实现思路就是设置 ImageView 的内边距,然后给 ImageView 一个白色带圆角形状的背景,这样看上去就有了一个带圆角的边框。

这里就直接上 Java 代码了。

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
    /* ImageView */
    /* this 是 Activity 实例 */
    ImageView adImageView = new ImageView(this);

    RelativeLayout.LayoutParams adImageLayoutParams = new RelativeLayout.LayoutParams(
        LayoutParams.MATCH_PARENT,
        LayoutParams.MATCH_PARENT);

    /* 这里设置外边距为了和关闭按钮形成位置对齐 */
    int adImageMargin = dp2px(CLOSE_BUTTON_SIZE / 2);
    adImageLayoutParams.setMargins(
        adImageMargin,
        adImageMargin,
        adImageMargin,
        adImageMargin);

    /* 这里设置内边距来实现白色边框 */
    int adImagePadding = dp2px(4);
    adImageView.setPadding(
        adImagePadding,
        adImagePadding,
        adImagePadding,
        adImagePadding);
    adImageView.setLayoutParams(adImageLayoutParams);

    /* 白色带圆角形状的背景 */
    GradientDrawable adImageBackground = new GradientDrawable();
    adImageBackground.setShape(GradientDrawable.RECTANGLE);
    adImageBackground.setColor(Color.WHITE);
    adImageBackground.setCornerRadius(dp2px(6));
    adImageView.setBackground(adImageBackground);

关闭按钮和图片视图的位置布局

通过整体布局我们已经得到了一块儿居中并占屏幕 76% 大小的区域。这里我们要做的是把图片视图和关闭按钮放置到这个区域里并保证关闭按钮的位置始终在图片视图的右上角。

这里我们的思路是在这块区域放置一个相对布局 RelativeLayout,然后把关闭按钮放进这个相对布局中并让关闭按钮的位置保持在布局的右上角固定不动。图片视图要有一个外边距,外边距的宽度正好是关闭按钮大小的一半,这样就使得关闭按钮的中心点正好和图片视图的右上角点重合。

老规矩先放出 XML 布局代码

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
  <!-- 占 76% 宽度的中间 -->
  <LinearLayout
      android:id="@+id/contentLayout"
      android:orientation="vertical"
      android:layout_width="0"
      android:layout_height="match_parent"
      android:layout_weight="76">
      
      <RelativeLayout
          android:id="@+id/imageLayout"
          android:layout_width="match_parent"
          android:layout_height="match_parent">
      
          <ImageView
              android:id="@+id/adImage"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:layout_marginTop="12dp"
              android:layout_marginLeft="12dp"
              android:layout_marginRight="12dp"
              android:layout_marginBottom="12dp"
              android:paddingTop="4dp"
              android:paddingLeft="4dp"
              android:paddingRight="4dp"
              android:paddingBottom="4dp"
              android:background="@drawable/image_view_background"
          />
      
          <Button
              android:id="@+id/closeButton"
              android:layout_width="24dp"
              android:layout_heigth="24dp"
              android:layout_alignParentTop="true"
              android:layout_alignParentRight="true"
          />
      
      </RelativeLayout>
  </LinearLayout>

至于 Java 代码上面的章节已经都贴过了,这里就不再重复粘贴了,大家翻看上面的代码来感受一下吧。

全屏和背景半透明

这个只有三行代码,直接放出了

1
2
3
4
5
6
7
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    getWindow().setFlags(WindowManager.LayoutParams. FLAG_FULLSCREEN, WindowManager.LayoutParams. FLAG_FULLSCREEN);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    getWindow().setBackgroundDrawable(new ColorDrawable(Color.parseColor("#75000000")));
}

Raspberry Pi 入门

第一块树莓派的板子还是 2012 年 10 月份入手的,那时候还是 Mod B,700 MHz 的 CPU 和 512 MB 内存。现在已经是树莓派 3 了,时间如白驹过隙啊。当时入手了一块,后来有个朋友想要在家里搞 NAS,忽悠他用树莓派搞,他也买了一块,后来他放弃了,把手里的那块树莓派送给我了,于是我就有了两块树莓派的板子。

其实我也等于是半放弃的状态。用树莓派刷过 XBMC(才发现这货也改名叫 Kodi 了,时间如白驹过隙啊) 来当电视盒子玩耍,后来还是买了小米盒子。再后来刷上 Raspbian 系统链接上硬盘准备做远程 BT 下载机,后来买了极路由发现这货就可以满足需求。再再后来闺女出生,拿树莓派接上罗技的摄像头来玩远程监控,然后,就没有然后了,放在书柜里开始吃灰了。

最近想做一些 Web 相关的开发,又把树莓派从书柜里拿了出来,上电后发现能用,于是又折腾起来。也是很久没关注树莓派的发展了,去官网一看,变化还挺大的。晚上花了两三个小时从重新刷系统到布置好整个的运行环境,准备把折腾过程简单总结一下,做个入门教程。

可能不大适合纯小白入门

如果你是一名刚刚才接触树莓派的新手,那么这个教程可能并不适合你,主要原因是我不需要图形界面,只通过 SSH + 命令行的方式进行操作。而如果你已经是树莓派圈子的“小鸟”、“老鸟”、“大牛”那么这篇你读来可能也没什么意思,呵呵。

刷入 Raspbain 系统

安装方式都有些变化了,有个叫做 NOOBS 的东东成了最佳的安装配置工具。大概了解了一下,主要是图形化安装,集成了众多可以用于树莓派的操作系统,操作简便。不过这个完全不复合我的需求,我不需要图形界面,所以我还是用了传统的镜像刷入方式来弄。

系统选择了最官方的 Raspbian 不过用的是 Lite 版本,还是因为不需要图形界面嘛,所以就不要桌面支持了。下载的是个 ZIP 包,解压之后是个 img 镜像,我是 Mac OS X ,直接用 diskutil + dd 命令将系统刷入 SD 卡,这一步树莓派官方网站的文档写的很清楚,操作也简单。

  • 1、将 SD 卡插入电脑,运行 diskutil list 显示出目前已挂在的磁盘。这里假设你的 SD 卡的磁盘 ID 是 disk4/dev/disk4

  • 2、用 diskutil unmountDisk 命令卸载掉已挂载的 SD 卡,例如你的 SD 卡的磁盘 ID 是 disk4 那么就运行:

1
diskutil unmountDisk /dev/disk4
  • 3、用 dd 命令将 Raspbian 系统镜像刷入 SD 卡。例如你的 SD 卡的磁盘 ID 是 disk4 那么就运行:
1
sudo dd bs=1M if=2016-03-18-raspbian-jessie.img of=/dev/rdisk4

稍等片刻 Raspbian 系统就成功刷入 SD 卡了。接下来插上网线,上电开机。

配置 Raspbain 系统

我家里的路由器是极路由,其实大多路由器都可以,登录到你的路由器管理界面找到接入到网络的树莓派的内网 IP 地址,然后就可以通过 SSH 来登录了:

1
ssh pi@192.168.xxx.xxx

默认密码是 raspberry。如果你成功登录了树莓派那么就可以开始进行配置了,配置操作也方便的很,一个命令搞定:

1
sudo raspi-config

这个命令会显示一个配置菜单,有不少项目需要配置,我一个一个说。

1. Expand Filesystem

这个选项可以让你刚刚刷入的 Raspbian 系统使用 SD 卡上的全部空间。第一步要做这个是因为这个操作需要重启后才能生效。做完后重启树莓派然后通过 SSH 重新登录。如果你的路由器支持,最好设置一下 DHCP 给树莓派分配一个固定的 IP 地址。

2. Change User Password

修改当前用户 pi 的密码,这个你现在做也可以,之后用命令 password pi 来做也成,总之建议还是改一下密码,毕竟默认密码 raspberry 是众所周知的。

3. Boot Options

启动选项,说白了就是开机后是默认进入图形桌面还是进入命令行,我是没有图形桌面的,所以确认选择:

B1 Console Text console, requiring user to login

4. Internationalisation Optins

这个选项下面需要修改两个子项,localetimezone

locale 我选择了

en_GB.UTF-8 UTF-8

en_US ISO-8859-1

en_US.UTF-8 UTF-8

zh_CN.UTF-8 UTF-8

zh_CN GB2312

zh_CN.GB18030 GB18030

默认本地化选项我选择了 en_US.UTF-8

本地化选项你也完全可以通过编辑 /etc/locale.gen 文件来配置,就是将文件中上述本地化项目的注释去掉即可。如果你通过 /etc/locale.gen 来修改的话,修改完毕后要记得使用命令来更新本地化设置

1
sudo locale-gen

另外在通过 SSH 登录后你很有可能收到这样的警告:

1
2
3
-bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
-bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
-bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)

不要着急,编辑 /etc/locale.conf 文件,做如下设置

1
2
LANG=en_US.UTF-8
LC_ALL=en_US.UTF-8

timezone 时区设置没什么可说的,根据你所在地区做出选择,我选择的是 Asia/Shanghai

5. Add to Rastrack

这个挺有意思。如果你对隐私什么的没有额外的洁癖,可以打开这个选项。它会将你的树莓派的地理位置和其他全世界使用树莓派的小伙伴们标记在 Google Map 上面,并可以通过 rastrack.co.uk 这个网站查看。

使用无线网卡

网线大大限制了树莓派的便捷性,给树莓派配上个 USB 的无线网卡就舒服多了。对于无线网卡的选择建议你千万不要盲目,看一下树莓派的硬件兼容列表再下单也不迟,否则买到不兼容的硬件就呵呵了(我第一次给树莓派购买的 SD 卡就因为不兼容而呵呵了)。我使用的是 EDUP EP-N8508GS黄金版 迷你USB无线网卡

插上你购买的 USB 无线网卡,通过运行命令 sudo lsusb 来查看,如果你看到

1
2
3
4
Bus 001 Device 004: ID 0bda:8176 Realtek Semiconductor Corp. RTL8188CUS 802.11n WLAN Adapter
Bus 001 Device 003: ID 0424:ec00 Standard Microsystems Corp. SMSC9512/9514 Fast Ethernet Adapter
Bus 001 Device 002: ID 0424:9512 Standard Microsystems Corp. LAN9500 Ethernet 10/100 Adapter / SMSC9512/9514 Hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub

能找到标有 802.11n WLAN Adapter 字样,或者运行命令

1
ifconfig -a

能看到标有 wlan0 字样,那么恭喜你,说明你的 USB 无线网卡是可用的了。下面我们在做配置。首先是 /etc/network/interfaces 文件

1
2
3
4
5
6
7
8
9
10
11
auto lo
iface lo inet loopback

auto eth0
iface eth0 inet dhcp

auto wlan0
allow-hotplug wlan0
iface wlan0 inet dhcp
wpa-conf /etc/wpa_supplicant/wpa_supplicant.conf
iface default inet dhcp

由于我的设备的内网 IP 都是路由器通过 DHCP 分配的,如果你的不是,那么配置文件的内容是有所不同的。接下来是 /etc/wpa_supplicant/wpa_supplicant.conf 文件,这个文件主要是配置要接入的 wifi 的帐号密码。如果你不确定你要接入的 wifi 的 ssid ,可以使用下面这个命令叫无线网卡扫描一下身边的 wifi 热点

1
sudo iwlist wlan0 scan

然后配置你的 wifi 接入帐号配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1

network={
  ssid="wifi-001"
  key_mgmt=WPA-PSK
  psk="wifi-001-password"
}

network={
  ssid="wifi-002"
  key_mgmt=WPA-PSK
  psk="wifi-002-password"
}

上述 wifi 使用的是 WPA/WPA2 加密,这里有几点需要注意的

    1. 如果你的 wifi 没有密码,那么 key_mgmt=NONE 并去掉 psk
    1. 如果你的 wifi 是 WEP 加密,那么 key_mgmt=NONE 并去掉 psk 加上 wep_key0="wifi-wep-password"

然后执行下面的命令启动无线网卡

1
sudo ifup wlan0

如果命令执行没有报错,那么再进入你的路由器管理后台,如果能看到你的树莓派的无线网卡也已经接入到网络了,那么恭喜你,你可以拔掉网线了,你的树莓派已经拜托了网线的束缚。

关闭无线网卡的休眠功能

在使用的过程中我发现经常出现 SSH 无法链接到树莓派,过一会儿又可以链接;树莓派的 HTTP 80 端口经常莫名其妙的就无法访问,然后过一会儿就可以访问。一开始我以为是树莓派不稳定,可是每次无法链接时我去查看路由器后台发现树莓派的网络接入都是正常的,直到后来我才明白,无线网卡默认是可以休眠的。通过下面的命令检查你的无线网卡是否也是自动休眠的:

1
cat /sys/module/8192cu/parameters/rtw_power_mgnt

如果命令返回值为 1 那么你的无线网卡也是自动休眠的,如果返回的是 0 则没有开启休眠功能。如果是 1 那么你需要手动关闭无线网卡的休眠功能,具体方式是编辑文件 /etc/modprobe.d/8192cu.conf(如果文件不存在则新建)加入如下配置项目

1
2
# Disable power saving
options 8192cu rtw_power_mgnt=0

重启一下树莓派,然后再次运行 cat /sys/module/8192cu/parameters/rtw_power_mgnt 命令,如果返回值变为了 0 那么你的无线网网卡的休眠功能就被关闭了。这下树莓派的链接就稳定了。

路由器的端口转发和动态域名解析

如果你不安于只在内网环境下摆弄树莓派,想要像我一样把树莓派放在家里,到了公司照样可以愉快的玩耍你的树莓派,那么你就需要做端口转发和动态域名解析。当然要能做到这两点,你需要满足

  • 1、 你的树莓派接入的网络有公网 IP

  • 2、 你的树莓派接入的路由器支持端口转发,当然能支持动态域名解析就更完美了。

一般的一级宽带运营商都可以满足第一条,但是有些小的第三方宽带运营商是不行的。像端口转发这种功能一般高级一点儿的路由器都是可以的,或是像小米路由、极路由这种性价比比较不错的路由器也是有的。具体的操作看自己路由器的支持和设置了,这里就不详述了。

到此树莓派的运行环境基本就搭建完毕了,至于接下来要怎么折腾,怎么玩耍,那就看大家的脑洞有多大了,最后祝大家玩的愉快。


也说 Android Apk 打包

我们在开发 Android 应用程序的时候一般都只需要使用 Eclipse 或是 Android Studio 这样的 IDE 编写好业务逻辑,最终由这些开发工具来协助我们将代码打包生成最终可以在设备上运行的 APK 包。正因为有这些集成度很高的开发工具,我们很少能够接触到 Android APK 包生成的具体流程。

其实如果你 Google 一下“Android apk 打包流程”或者“手动打包 Android apk”,能找到很多介绍 Android APK 打包流程的内容,而我为什么又要再多写一篇呢?是因为这些内容大多只介绍了一个标准的 Android 工程是如何一步步变成 apk 包的,而很少有写一个标准 Android 工程附带依赖几个 Android Library 的情况又是怎样的。要知道在国内的 Android 开发环境下你的产品不接入几个第三方 SDK 你都不好意思出门跟人家到招呼!而往往一个产品根据发放的渠道不同,又要接入不同的第三方 SDK。所以我想从这些方面入手来写这一篇。另外,网上大多数文章都已经有些过时了,例如大多数最后都提到用 apkbuilder 这个脚本来生成最终的 apk 包,而实际上目前最新的 Android SDK 早就已经移除了这个脚本,更改了最终生成 apk 包的方式。

概述

一个 Android 工程最后变成 apk 包大概要做这么几件事儿:

  • 1、生成 R.java 文件
  • 2、将 .java 文件编译成 .class 文件
  • 3、将 .class 文件打包成 .jar 文件
  • 4、将所有 .jar 文件(包括依赖库)编译成 classes.dex 文件
  • 5、将 assetsres 文件夹中所有的资源文件打包成一个 apk
  • 6、将 classes.dex 文件添加进 apk
  • 7、如果有使用 NDK 技术的话,将生成的 .so 文件添加进 apk
  • 8、对 apk 包进行签名

说白了一个 apk 包就是由代码资源组成的。代码的处理基本上就是编译,这个没什么可说的。我们主要说说资源打包。我们通过亲手实践来理解 apk 生成中的资源处理过程。

建立试验环境

找一个空白目录,建立一个标准的 Android 工程,再建立两个标准的 Android Library 工程。

1
2
3
4
5
$android create project --activity MainActivity --package com.leenjewel.test --path ./AndroidTestProject -t android-21

$android create lib-project --package com.leenjewel.test.liba --path ./AndroidLibProjectA -t android-21

$android create lib-project --package com.leenjewel.test.libb --path ./AndroidLibProjectB -t android-21

怎么样?是不是有些同学连命令行手工建立 Android 工程都是头一次看到啊?可以去刚刚新建好的三个工程的目录看看,其实 Android 的标准工程和 Library 工程并没有什么太大的区别。

【试验1】第一次生成 R.java 文件

然后我们切换到刚刚建立的标准 Android 工程的目录下面,准备手工生成 R.java 文件。R.java 文件是什么我就不解释了,这里我们根据主项目和两个 Libary 项目分别生成三个 R.java 文件,后面我们再做解释,先一步一步跟着做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$aapt package -m -J ./gen -M ./AndroidManifest.xml \
    -S ./res \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    --auto-add-overlay

$aapt package -m -J ./gen -M ../AndroidLibProjectA/AndroidManifest.xml  \
    -S ./res \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    --auto-add-overlay \
    --non-constant-id

$aapt package -m -J ./gen -M ../AndroidLibProjectB/AndroidManifest.xml \
    -S ./res  \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    --auto-add-overlay \
    --non-constant-id

如果三个命令都执行成功的话会在刚刚建立的标准 Android 工程根目录的 gen 目录下根据不同的子目录产生三个 R.java 文件:

  • ./gen/com/leenjewel/test/R.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.leenjewel.test;

public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static final int ic_launcher=0x7f020000;
    }
    public static final class layout {
        public static final int main=0x7f030000;
    }
    public static final class string {
        public static final int app_name=0x7f040000;
    }
}
  • ./gen/com/leenjewel/test/liba/R.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.leenjewel.test.liba;

public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static int ic_launcher=0x7f020000;
    }
    public static final class layout {
        public static int main=0x7f030000;
    }
    public static final class string {
        public static int app_name=0x7f040000;
    }
}
  • ./gen/com/leenjewel/test/libb/R.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.leenjewel.test.libb;

public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static int ic_launcher=0x7f020000;
    }
    public static final class layout {
        public static int main=0x7f030000;
    }
    public static final class string {
        public static int app_name=0x7f040000;
    }
}

【试验1】观测结果:

  • 1、三个 R.java 文件中资源的 ID 相同。
  • 2、三个 R.java 文件的 package 不同。
  • 3、Library 工程生成的 R.java 文件的资源属性没有 final 标识。

第一个试验我们得出了三个观测结果,我们的试验继续。通过刚刚生成的三个 R.java 文件不难看出现在这三个工程各自持有的资源是一样的,接下来我们先要给这三个工程加点儿不一样的东西进去。

特别提示:

接下来为了更好的观察试验结果,我们可以用熟悉的代码管理工具如 git 或 svn 给主工程 AndroidTestProject 初始化一个代码版本库并进行一次提交,这样通过 diff 我们可以更方便的观察试验变化。

每一个工程的 res/drawable-*/ 目录下面都有一个自动生成的 ic_launcher.png 文件,他们的内容都是一样的:“经典的 Android 机器人”。为了区分他们,我们随便用个图片编辑工具在两个 Library 工程的 ic_launcher.png 的小机器人胸前按照它们所属的项目分别打上 AB 两个标签。

然后再分别编辑 res/values/strings.xml 资源文件,加入一些新的字符串节点。

  • AndroidTestProject/res/values/strings.xml
1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">MainActivity</string>
    <!-- new!!! -->
    <string name="same_thing">SameP</string>
    <string name="p_thing">PThing</string>
</resources>
  • AndroidLibProjectA/res/vaues/strings.xml
1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">A</string>
    <!-- new!!! -->
    <string name="same_thing">SameA</string>
    <string name="a_thing">AThing</string>
</resources>
  • AndroidLibProjectB/res/values/strings.xml
1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">B</string>
    <!-- new!!! -->
    <string name="same_thing">SameB</string>
    <string name="b_thing">BThing</string>
</resources>

做完了这些改变后如果已经给主工程建立了代码库的同学这个时候可以再次提交一下。然后我们准备进行下一个试验。

我们先再次重新生成一下前面刚刚生成过的三个 R.java 文件。生成后可以发现新增加了几个资源 ID,但是我们刚刚观测的结果没变,生成的三个 R.java 文件的资源 ID 依然是一致的。出于篇幅考虑这里就不再放出三个 R.java 文件的内容了,只放了主工程的 R.java 文件内容。

  • AndroidTestProject/gen/com/leenjewel/test/R.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.leenjewel.test;

public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static final int ic_launcher=0x7f020000;
    }
    public static final class layout {
        public static final int main=0x7f030000;
    }
    public static final class string {
        public static final int a_thing=0x7f040003;
        public static final int app_name=0x7f040000;
        public static final int b_thing=0x7f040002;
        public static final int p_thing=0x7f040004;
        public static final int same_thing=0x7f040001;
    }
}

这里我们修改过资源后重新生成三个 R.java 文件,再次提交,准备新的试验。

【试验2】改变资源路径顺序重新生成 R.java 文件

以主工程为例,我们刚刚一直使用的生成主工程 R.java 文件的命令是:

1
2
3
4
5
6
$aapt package -m -J ./gen -M ./AndroidManifest.xml \
    -S ./res \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    --auto-add-overlay

现在我们调整一下这个命令里 -S 参数的顺序,将 AndroidLibProjectA/res 放在第一,其他不变:

1
2
3
4
5
6
$aapt package -m -J ./gen -M ./AndroidManifest.xml \
    -S ../AndroidLibProjectA/res \
    -S ./res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    --auto-add-overlay

这时再次生成主工程的 R.java 文件看看有什么变化:

  • AndroidTestProject/gen/com/leenjewel/test/R.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.leenjewel.test;

public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static final int ic_launcher=0x7f020000;
    }
    public static final class layout {
        public static final int main=0x7f030000;
    }
    public static final class string {
        public static final int a_thing=0x7f040004;
        public static final int app_name=0x7f040000;
        public static final int b_thing=0x7f040002;
        public static final int p_thing=0x7f040003;
        public static final int same_thing=0x7f040001;
    }
}

【试验2】观测结果:

  • 1、改变资源路径顺序后 R.java 文件中资源 ID 的值发生了变化。

注意,这次我们不提交文件的变动,将主工程还原到调整资源路径顺序之前的状态然后继续我们的试验。

【试验3】将资源生成 APK 包

下面我们要将主工程和两个依赖库工程的资源打包,这也是 Android 应用程序 apk 包生成过程中的步骤之一。这里我们直接给出命令:

1
2
3
4
5
6
7
$aapt package -f -M AndroidManifest.xml \
    -S ./res \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    -F ./out/res.apk \
    --auto-add-overlay

这个命令看上去多多少少和刚刚生成 R.java 的命令有些类似,如果执行顺利的话会在主工程根目录下面的 out 目录下生成一个叫做 res.apk 的包。当然这个包并不能在 Android 设备上运行,这只是个半成品,我们继续。

了解 Android 开发的人都知道 apk 其实就是一个 zip 压缩包,用 zip 解压缩软件就可以进行解压缩操作。但是当我们尝试通过 zip 解压缩我们刚刚生成的 res.apk 后发现里面并不是简简单单的将我们的资源文件直接打包进去而是额外的还做了一些类似于编译的操作,例如我们之前编辑过的 res/values/strings.xml 资源就已经被“编译”成为 resources.arsc 文件的一部分,而诸如 AndroidManifest.xml 这样的文件也不再是明文的我们可以看懂的格式了。

这时我们需要一个工具将我们刚刚生成的这个 res.apk 包“反编译”回来,这个好用的工具就是 apktool,下载这个工具后我们来“反编译”我们的 apk 包:

1
$java -jar apktool.jar d res.apk

如果执行顺利的话我们在 res.apk 包所在的同级目录下可以看到一个 res 目录,里面就是刚刚通过 apktool 反编译回来的文件,我们重点观察两个文件:

  • ic_launcher.png 文件
  • res/values/strings.xml 文件

由于之前我们给主工程和两个依赖库工程的 ic_launcher.png 文件做过标记,所以这时候不难看出:

【试验3】观测结果:

  • 1、res.apk 包中 ic_launcher.png 文件来自 AndroidTestProject 即来自主工程。
  • 2、res.apk 包中 res/values/strings.xml 文件的资源 same_thingapp_name 的值来自 AndroidTestProject 即来自主工程。

  • res/values/strings.xml 文件

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">MainActivity</string>
    <string name="same_thing">SameP</string>
    <string name="b_thing">BThing</string>
    <string name="a_thing">AThing</string>
    <string name="p_thing">PThing</string>
</resources>

不用提交任何代码,我们试验继续。

【试验4】改变资源路径顺序重新生成 APK 包

把刚刚第一次生成的 res.apk 和用 apktool 反编译出来的 res 目录都删掉。为了方便对比,再看一眼刚刚用来生成 apk 的命令:

1
2
3
4
5
6
7
$aapt package -f -M AndroidManifest.xml \
    -S ./res \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    -F ./out/res.apk \
    --auto-add-overlay

然后我们依然是调整一下 -S 命令参数的顺序和刚才一样我们把主工程的 res 目录和依赖库工程 AndroidLibProjectA 的 res 目录调换顺序再生成一下 apk 包看看:

1
2
3
4
5
6
7
$aapt package -f -M AndroidManifest.xml \
    -S ../AndroidLibProjectA/res \
    -S ./res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    -F ./out/res.apk \
    --auto-add-overlay

重新生成新的 res.apk 包之后我们还是用 apktool 将包进行反编译然后观察:

【试验4】观测结果:

  • 1、res.apk 包中 ic_launcher.png 文件来自 AndroidLibProjectA 即来自依赖库 A 工程。
  • 2、res.apk 包中 res/values/strings.xml 文件的资源 same_thingapp_name 的值来自 AndroidLibProjectA 即来自依赖库 A 工程。

  • res/values/strings.xml 文件

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">A</string>
    <string name="same_thing">SameA</string>
    <string name="b_thing">BThing</string>
    <string name="p_thing">PThing</string>
    <string name="a_thing">AThing</string>
</resources>

如果你觉得意犹未尽那么可以尝试再次调整资源路径顺序,这次把依赖库 B 工程的资源目录的顺序放在最前面然后重复试验 4 的步骤再次生成 res.apk 包并使用 apktool 反编译之后观察试验结果。而我们不打算再这么做了,因为结果已经很明确了。

试验就暂时做到这,接下来我们说说通过这四个无聊的小试验我们了解到了什么。

细心的同学相比已经有结论了。如果单单只是一个 Android 标准工程的资源打包的话那就轻松很多了,直接一个命令就搞定。而附带诸多 Library 工程依赖的话就要考虑资源的合并问题了,而资源合并过程中主要要处理的就是同名资源问题。通过我们做过的试验我们可以得出一个结论即:

Android 的资源打包过程中对于资源特别是同名资源的处理只和资源路径的先后顺序有关系,只要资源路径顺序不变,在 R.java 文件中生成的资源引用 ID 就不变

资源打包的事情就说到这里。顺带提一句,上面的试验中并没有出现 assets 资源文件打包的情况,其实 assets 资源文件打包和 res 资源文件打包的原理和规则都是一样的,加上 assets 资源打包的命令看起来大概是这个样子的

1
2
3
4
5
6
7
8
9
10
$aapt package -f -M AndroidManifest.xml \
    -A ./assets \
    -A ../AndroidLibProjectA/assets \
    -A ../AndroidLibProjectB/assets \
    -S ./res \
    -S ../AndroidLibProjectA/res \
    -S ../AndroidLibProjectB/res \
    -I ~/Dev/android-sdk-macosx/platforms/android-21/android.jar\
    -F ./out/res.apk \
    --auto-add-overlay

assets 资源文件对于同名文件资源的合并处理规则也是和资源路径顺序有关。

编译源代码

  • 编译 java
1
2
3
4
5
6
7
8
javac -target 1.7  -source 1.7 \
    -d  ./bin \
    -bootclasspath  ~/Dev/android-sdk-macosx/platforms/android-21/android.jar \
    -classpath ./libs/lib1.jar:./libs/lib2.jar:./libs/lib3.jar \
    ./src/com/package/your/xxxx.java \
    ./src/com/package/your/xxxxx.java \
    ./src/com/package/your/xxxxxx.java
    ....

这里要说的是 Android Library 工程和 Android 主工程的源代码是分开编译的。因为主工程中的代码是依赖 Library 工程的,所以要将所有 Library 工程编译并生成 jar 文件,然后再编译主工程时将所有 Library 工程的 jar 引入 classpath 中。

主工程编译的时候记得要连同之前生成在 gen 文件夹中的 R.java 文件和 src 文件夹下面的所有源代码一起编译。

  • 生成 jar
1
2
jar  cvf  ./bin/classes.jar \
    -C ./bin  .
  • 生成 dex
1
2
3
4
5
6
7
8
9
10
11
dx  --dex \
    --output  ./bin/classes.dex \
    ./bin/classes.jar \
    ./libs/lib1.jar \
    ./libs/lib2.jar \
    ./libs/lib3.jar \
    ../AndroidLibProjectA/libs/lib1.jar \
    ../AndroidLibProjectA/libs/lib2.jar \
    ../AndroidLibProjectB/libs/lib1.jar \
    ../AndroidLibProjectB/libs/lib2.jar
    ......

这样所有的 Java 代码和依赖库就全部被编译成最终 Android apk 中要用到的 classes.dex 文件了。

生成最终的 apk 包

  • 添加 classes.dex 文件

然后我们把生成的 classes.dex 文件丢进刚刚生成的资源 apk 包里。原本这步是通过 apkbuilder 脚本来做的,现在 Google 统一改成用 aapt 命令来做了。

1
2
cd ./bin
aapt add ../out/res.apk  classes.dex

这里需要注意的是 classes.dex 文件前面一定不要加额外的路径,如果加了路径那么这些路径会一并带进 apk 包里。而 Android apk 包里的 classes.dex 文件是直接放进包里面的,不能带路径。

  • 添加 .so 文件

如果你使用了 NDK 技术或者有库依赖,那也需要将这些依赖的 .so 文件添加进 apk 包,方法和添加 classes.dex 一样。

1
$aapt add res.apk lib/armeabi/xxx.so

据说 Windows 环境下执行上述 aapt add 命令时路径也要用类 Unix 系统的斜杠而不能用 Windows 的反斜杠,我手边没有 Windows 环境没有亲自尝试。

  • 签名
1
2
3
4
5
6
7
8
jarsigner  -digestalg  SHA1 \
    -sigalg  MD5withRSA \
    -verbose \
    -keystore  /your/keystore/file/path/xxxx.keystore \
    -storepass  xxxxxxxx \
    -keypass  xxxxxxxx \
    -signedjar  ./out/res.signed.apk \
    ./out/res.apk  "your keystore alias"
  • 优化 apk 包

这是 Google 官方文档提到的将 apk 包文件做一下对齐操作。

1
zipalign  -f  4  ./out/res.signed.apk  ./out/your.final.apk

至此一个可放在 Android 设备上运行的 apk 包就成功的生成了。

Android 第三方库的集成方式

前面也说了在国内特殊的 Android 开发环境下你的 Android 应用不接入几个第三方 SDK 你出门都不好意思和别人打招呼。甚至有时我们为了针对各种不同的渠道,一个 Android 应用程序出包时要不停的切换各种第三方 SDK 依赖,想必搞过 Android 开发的人都知道这是个很头疼的问题。

有了前面针对 Android apk 打包流程的介绍,我们最后再来探讨一下 Android 出包,特别是接入各种第三方 SDK ,针对不同渠道切换不同依赖出包的更为灵活的方式的思路。

【集成方式1】以标准 Android Library Project 方式集成

我们一般在使用自己内部开发的一些公共库或公共组件时会选用这种方式。所有的资源和代码都以标准的 Android Library Porject 的形式呈现,开发应用时只需要声明引用这些库工程即可。

通过刚刚的试验我们知道只要引入资源的路径顺序不变,主工程和依赖库工程所生成的 R.java 文件中资源 ID 的值是一样的。所以在开发依赖库工程时,在依赖库工程中通过自己的 R 类来引用资源写好的逻辑代码在主工程中同样是可以正常工作的。

【集成方式2】依赖库通过复制粘贴进主工程的方式集成

如果我们开发的库要发布给第三方使用的时候,一般会将代码混淆打包成 jar 文件连同资源文件一起发送给第三方。第三方拿到这样形式的 SDK 后一般会通过复制粘贴的方式将资源文件拷贝进主工程的 resassets 文件夹内,将 jar 包拷贝进主工程的 libs 文件夹内。

这时由于库使用的资源文件是直接放置在主工程中的,所以最终生成的资源 ID 是不同的。存在于第三方库 jar 包中的库逻辑如果还是按照 R 类来引用资源的话就不行了。因为库逻辑是不可能预先知道主工程的包名的,这时只能通过 Java 的“反射”机制来进行资源引用了。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private int getResId(String resType, String resName) {
  try {
      Class localClass = Class.forName(getPackageName() + ".R$" + resType);
      Field localField = localClass.getField(resName);
      return Integer.parseInt(localField.get(localField.getName()).toString());
  } catch (ClassNotFoundException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
  } catch (NoSuchFieldException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
  } catch (NumberFormatException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
  } catch (IllegalAccessException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
  } catch (IllegalArgumentException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
  }
  return 0;
}

这是个通过 Java “反射”机制查找资源 ID 的方法。一般我们引用资源时会这样写:

1
Button btn = (Button)findViewById(R.id.btn1);

而在这种方式引入的依赖库中一般会通过上述的自有方法来获取资源 ID

1
Button btn = (Button)findViewById(getResId("id", "btn1"));

【集成方式3】通过二次打包的方式集成

其实说到针对不同渠道不同第三方 SDK 依赖出包的问题,很多同学首先想到的就是使用如 AnySDK 这样的一整套解决方案。使用这种方案你只需要在你的主项目中薄薄的接入一层 AnySDK 的中间层 SDK 依赖,然后将你生成的 apk 包交给 AnySDK 的客户端,通过 UI 界面选择好你需要的第三方 SDK 依赖,然后等待进度条走完之后一个复合你要求的渠道包就生成出来了。

可是在你享受这种方便的时候是否思考过它是如何实现的呢?

当你依赖的第三方 SDK 并不在 AnySDK 这种解决方案提供商的支持之列,而支持计划遥遥无期的时候,当你生成的渠道包发生问题去寻求 AnySDK 的反馈而没有响应时,你是否咬牙切齿的想要自己也搞这么一套方便的解决方案把一切掌控在自己手中呢?

这里就告诉你它们所使用的是什么样的黑科技

其实了解了 Android apk 的打包原理后,只要稍稍想一想就会明白所谓的黑科技也并不是什么复杂的东西,无非是:

  • 建立统一的接口规范,将各个第三方 SDK 的调用方式统一化、规范化。

  • 将各个第三方 SDK 按照统一接口规范做一次二次封装,使其暴露的对外调用接口统一。

  • 只将薄薄的一层统一调用接入标准工程项目中,排除了各种第三方依赖。

  • 通过二次打包的方式将所需的各个第三方 SDK 打进 apk 包。

  • 最后运行时通过动态加载技术经过统一的接口调用各个第三方 SDK。

这里的重点在于二次打包。当然不排除各家类 AnySDK 解决方案提供商有各家的方式方法,不过大概的原理是相通的。

  • 用类似 apktool 这样的工具将原 apk 包解包。

  • 通过 aapk 工具将各个第三方 SDK 中的资源合并,重新生成 R.java 并编译成 R.class 文件替换原包中的 R.class 文件,然后根据各个第三方 SDK 的包路径不同对应生成各个包下的 R.java 文件。

  • 将新生成的各个 class 文件和各个第三方 SDK 中的依赖 jar 包和原 apk 包中的 class 文件一起重新编译成 classes.dex 文件。

  • 最后重新打包成新的 apk 然后重新签名一次即可。

最后的广告时间

MySDK 项目就是我仿照 AnySDK 这样的解决方案实现的一套框架。虽然还没有完工,但基本该有的东西都有了:

  • 统一接口的中间层 C++、Java、Objective-C、Cocos2d-x Lua 语言层面的调用支持。

  • 基于 Web 和基于命令行的二次打包工具

有兴趣的同学可以 clone 下来玩玩。附上简单玩法:

提前部署好你的 Android 环境。Android SDK 一定要有,并且要定义一个环境变量 $ANDROID_SDK_ROOT 指明 Android SDK 的位置。JDK 要安装这个更不用说了,还有 Python 环境。

Web 二次打包工具基于 Python 的 Tornado Web Framework 所以请先自行安装。然后:

1
2
3
4
5
6
7
8
9
10
11
$git clone git@github.com:leenjewel/mysdk.git

$cd mysdk

$cd apk.builder

$python setup.py install

$python mysdk.py start-server \
    --server-config ./../example/apk_build_test/mysdk_web_server/settings.py \
    --with-port 8080

如果命令执行成功的话,直接打开浏览器访问 http://localhost:8080/index 就可以看到二次打包工具的 Web UI 界面了。

先新建一个工作空间(workspace),点击 New Workspace 按钮新建好工作空间后继续点击 Go To Workspace 按钮进入新建好的工作空间。

在新建好的工作空间中点击 New Project 按钮新建一个二次打包项目。这里需要注意的是:

  • APK File 文件选择 example/apk_build_test/MySDKAPPExample.apk

  • APK Package Name 填写 com.leenjewel.mysdk.appexample

  • Keystore File 文件选择 example/android/MySDKAPPExample/keystore 文件

  • Store Password 和 Key Password 都填写 com.leenjewel.mysdk

  • Alias 填写 mysdk

  • aexamplesdk 点击 Add SDK 按钮添加,metadata_val_1 的值随便填写,这表示二次打包将这个名叫 aexamplesdk 的第三方 SDK 加入到原包中。

接着点击 Create Project 按钮完成二次打包项目的创建。

最后点击 Build Project 按钮进入到二次打包界面,再次确认项目配置填写无误的话直接点击二次打包界面里的 Build Project 按钮就开始二次打包操作了。

祝玩的愉快。如遇到问题欢迎和我讨论。


【学习Xv6】 内核概览

前情提要

上一篇《【学习Xv6】加载并运行内核》讲到内核已经成功加载到内存中开始运行了。内核可以说是一个操作系统最核心的部件了,所以涉及要讲的内容非常非常多,我们先缓一缓脚步,对内核有一个大致的了解然后在细细的去品味它。

内核的组成

要想知道内核里都有些什么还是要从 Makefile 入手看看组成内核都使用了那些源码文件。

1
2
3
4
kernel: $(OBJS) entry.o entryother initcode kernel.ld
    $(LD) $(LDFLAGS) -T kernel.ld -o kernel entry.o $(OBJS) -b binary initcode entryother
    $(OBJDUMP) -S kernel > kernel.asm
    $(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym

$(OBJS) 变量由于内容较多我们只列出其中的一部分

1
2
3
4
5
6
7
8
9
10
OBJS = \
  bio.o\
  console.o\
  exec.o\
  file.o\
  fs.o\
  ide.o\
  ioapic.o\
  kalloc.o\
  ......

从这些 .o 文件的文件名不难看出这些都是内核基本功能的组成部分,这也是我们以后研究的重点,既然这篇是概览我们暂时先不去关心这些组件。

除去组件剩下的文件就只有这几个:entry.Sinitcode.Sentryother.S 而这几个文件我们要从哪一个先入手呢?这要听 kernel.ld 文件的,因为链接器在链接生成最终的内核时也是听 kernel.ld 文件的安排的。

.ld 文件是链接器配置文件或者叫链接脚本,它有自己的一套语法,链接器最终会根据链接器配置文件中的规则来生成最终的二进制文件。这里我们就不做具体的语法介绍了,有兴趣的同学请自行 Google 吧,我们只解释一下几个关键点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Simple linker script for the JOS kernel.
   See the GNU ld 'info' manual ("info ld") to learn the syntax. */

OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)

SECTIONS
{
  /* Link the kernel at this address: "." means the current address */
        /* Must be equal to KERNLINK */
  . = 0x80100000;

  .text : AT(0x100000) {
      *(.text .stub .text.* .gnu.linkonce.t.*)
  }

      /* Adjust the address for the data segment to the next page */
  . = ALIGN(0x1000);

    // ......
}

这里只关注四点:

  • ENTRY(_start)  内核的代码为段执行入口:_start
  • . = 0x80100000 内核的起始虚拟地址位置为:0x80100000
  • .text : AT(0x100000) 内核代码段的内存装载地址为:0x100000
  • . = ALIGN(0x1000) 内核代码段保证 4KB 对齐

关于内核起始虚拟地址的问题我们后面遇到了再来说,代码段内存装载地址 0x100000 是不是看着眼熟?没错了,我们在上一篇《【学习Xv6】加载并运行内核》最后讲到 bootmain.c 文件加载并运行内核时看到过,这里再把上一篇的代码列出来大家回顾一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// bootmain.c

void
bootmain(void)
{
  struct elfhdr *elf;
  struct proghdr *ph, *eph;
  void (*entry)(void);
  uchar* pa;

  // 从 0xa0000 到 0xfffff 的物理地址范围属于设备空间,
  // 所以内核放置在 0x10000 处开始
  elf = (struct elfhdr*)0x10000;

  // 从内核所在硬盘位置读取一内存页 4kb 数据
  readseg((uchar*)elf, 4096, 0);

  // 省略后面的代码......
}

正式因为链接脚本强制规定了内核代码段在内存中的位置才保证了引导区程序可以顺利的按照约定的地址去引导 CPU 执行内核代码。

内核引导

既然知道了内核的入口点是 _start 通过简单的文本搜索不难找到这个入口点在 entry.S 文件中,我们直接先列出代码:

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
43
44
45
46
47
48
#include "asm.h"
#include "memlayout.h"
#include "mmu.h"
#include "param.h"

# Multiboot header.  Data to direct multiboot loader.
.p2align 2
.text
.globl multiboot_header
multiboot_header:
  #define magic 0x1badb002
  #define flags 0
  .long magic
  .long flags
  .long (-magic-flags)

# By convention, the _start symbol specifies the ELF entry point.
# Since we haven't set up virtual memory yet, our entry point is
# the physical address of 'entry'.
.globl _start
_start = V2P_WO(entry)

# Entering xv6 on boot processor, with paging off.
.globl entry
entry:
  # Turn on page size extension for 4Mbyte pages
  movl    %cr4, %eax
  orl     $(CR4_PSE), %eax
  movl    %eax, %cr4
  # Set page directory
  movl    $(V2P_WO(entrypgdir)), %eax
  movl    %eax, %cr3
  # Turn on paging.
  movl    %cr0, %eax
  orl     $(CR0_PG|CR0_WP), %eax
  movl    %eax, %cr0

  # Set up the stack pointer.
  movl $(stack + KSTACKSIZE), %esp

  # Jump to main(), and switch to executing at
  # high addresses. The indirect call is needed because
  # the assembler produces a PC-relative instruction
  # for a direct jump.
  mov $main, %eax
  jmp *%eax

.comm stack, KSTACKSIZE

这里我们直接略去 multiboot_header 这部分。这部分是为了方便通过 GNU GRUB 来引导 xv6 系统的。我们直接看 .globl _start 部分,入口只做了一件事儿就是转到 entry 段继续执行。不过别看这里只有一行代码,我们也要说一下。

V2P_WO 的定义在 memlayout.h 文件中:

1
2
#define KERNBASE 0x80000000         // First kernel virtual address
#define V2P_WO(x) ((x) - KERNBASE)

它的作用是将内存虚拟地址转换成物理地址。我们现在知道内核的虚拟地址起始于 0x80100000 而对应的内存物理地址是 0x100000 ,因为代码的偏移量是一样的即:

1
2
3
4
5
6
7
指令虚拟地址 = 0x80100000 + 偏移量
指令内存地址 = 0x100000 + 偏移量

所以运用初中解方程式的知识

执行内存地址 = 0x100000 + 指令虚拟地址 - 0x80100000
             = 指令虚拟地址 - 0x80000000

接下来我们继续看 entry 段做了什么。总结来说一共做了五件事儿:

  • 1、开启 4MB 内存页支持
  • 2、建立内存页表
  • 3、开启内存分页机制
  • 4、设置内核栈顶位置
  • 5、跳转到 main 继续执行

我们一件一件的说。

开启 4MB 内存分页支持

这是通过设置寄存器 cr4PSE 位来完成的。cr4 寄存器是个 32 位的寄存器目前只用到低 21 位,每一位的至位都控制这一些功能的状态,所以 cr4 寄存器又叫做控制寄存器。

PSE 位是 cr4 控制寄存器的第 5 位,当该位置为 1 时表示内存页大小为 4MB,当置为 0 时表示内存页大小为 4KB。

建立内存页表

这里先从内存的分页机制说起。之前我们已经接触过内存的分段管理机制了,和分段机制一样,分页机制同样是管理内存的一种方式,只不过这种方式相对于分段式来说更为先进,也是目前主流的现代操作系统所使用的内存管理方式。

通过分页将虚拟地址转换为物理地址这项工作是由 MMU(内存管理单元)负责的,以 x86 32 位架构来说它支持两级分页(Pentium Pro下分页可以是三级),这也是由 MMU 决定的。同时 x86 架构支持 4KB、2MB 和 4MB 单位页面大小的分页。当然无论以多少级进行分页,分页机制的原理是相通的,我们就以两级分页来说。

看下图是二级页表分页机制下虚拟地址转换为物理地址的过程,以 32 位系统为例我们知道 32 位系统的内存虚拟地址是 32 位的,这里我们先假设页面大小是 4KB (随后我们再说 4MB 页面的情况)。

在 4KB 页面大小情况下 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。

cr3 寄存器中保存着一级页表所在的内存物理地址,当给出一个虚拟地址后,根据 cr3 的地址首先找到一级页表在内存上的存放位置。上面我们说到虚拟地址的高 10 位为一级页表的索引,所以 2^10 = 1024 即一级页表一共有 1024 个元素,通过虚拟地址高 10 位的索引我们找到其中一个元素,而这个元素的值指向二级页表在内存中的物理地址。

同理,虚拟地址中紧挨着高 10 位后面的 10 位是二级页表索引,因此二级页表也有 1024 个元素,通过虚拟地址的二级页表索引找到其中的一个元素,该元素指向内存分页中的一个页的地址。

通过二级页表我们现在找到了内存上的一页物理页。根据现在的设定,一物理页的大小是 4KB,4KB 的内存上还是存在着很多不同的数据的,那么我们如何从这段 4KB 内存上取得我们想要的数据呢?别忘了在虚拟地址上还剩下低 12 位没用呢,2^12 = 4096 = 4KB 根据这最后 12 位的索引我们最终在内存上准确的找到了我们想要的数据。

而根据二级页表和每页内存的大小我们也不难算出:

1
1024个一级页表项 x 1024个二级页表项 x 4KB页面大小 = 4GB

正好是 32 位系统的最大内存寻址。

我们再通过下面这张示意图体会一下这个过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
寄存器                       虚拟地址
+---+        +---------------------------------------+
|cr3|        |  Page Dict  |  Page Table  |  Offset  |
+---+        |  31 -- 22   |  21 -- 12    |  11 -- 0 |
  |          +---------------------------------------+
__/                   |                   |       |-----------|
|  +--------+         |      +--------+   |    +--------+     |
|  |        |         |      |        |   |    |        |     |
|  +--------+         |      +--------+<--|    +--------+     |
|  |        |         |      |        |-----|  |        |     |
|  +--------+<--------|      +--------+     |  +--------+     |
|  |  PDE   |----|           |   PTE  |     |  |  Data  |     |
|  +--------+    |           +--------+     |  +--------+<----|
|  |        |    |           |        |     |  |        |
|  +--------+    |           +--------+     |  +--------+
|  |        |    |           |        |     |  |        |
|->+--------+    |---------->+--------+     |->+--------+
     一级页表                    二级页表          物理内存页

这里我们再额外算一笔账,二级页表中每个表项占 32 位,所以一个一级页表的总体积是 4byte x 1024 = 4KB,而每个一级页表项都对应一个二级页表,所以全部二级页表的总体积是 4KB x 1024 = 4MB,即二级页表分页机制自身内存占用也要约 4MB 外加 4KB。

我们还要额外提一下页表项。上面刚说过每个页表项占 32 位,它也是分两个部分:高 20 位是基地址,低 12 位是控制标记位。所以当我们通过一级页表索引在一级页表中查找时是这样的:

1
一级页表项地址 = cr3寄存器高20 + ( 10位一级页表索引 << 2 )

通过二级页表索引在二级页表中查找时是这样的:

1
二级页表项地址 = 一级页表项高20 + ( 10位二级页表索引 << 2 )

读到这里你是否可以理解 页表项索引左移 2 位 这个操作的意义?索引就好比数组的下标,而这里我们要通过下标得到具体的位置,如果一条笔直的马路上每隔 2 米就插一面旗子,我现在站在这条路的起点处(20位基地址)问你第 5 面旗子(下标索引)距离我多远(地址),那么你一定会算:5 x 2 = 10 米,那么同理:

1
2
3
页表项地址 = 基地址 + ( 索引 x 页表项大小 )
           = 基地址 + ( 索引 x 4字节 )
           = 基地址 + ( 索引 << 2 )

最后我们再看看页表项低 12 位控制位都代表什么意义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ 11 + 10 + 9 + 8 +  7 + 6 + 5 +  4  +  3  + 2  + 1  + 0 +
|    Avail    | G | PS | D | A | PCD | PWT | US | RW | P |
+--------------------------------------------------------+
|     000     | 0 |  1 | 0 | 0 |  0  |  0  |  0 |  1 | 1 |
+--------------------------------------------------------+

P     : 0 表示此页不在物理内存中,1 表示此页在物理内存中
RW    : 0 表示只读,1 表示可读可写(要配合 US 位)
US    : 0 表示特权级页面,1 表示普通权限页面
PWT   : 1 表示写这个页面时直接写入内存,0 表示先写到缓存中
PCD   : 1 表示该页禁用缓存机制,0 表示启用缓存
A     : 当该页被初始化时为 0,一但进行过读/写则置为 1
D     : 脏页标记(这里就不做具体介绍了)
PS    : 0 表示页面大小为 4KB1 表示页面大小为 4MB
G     : 1 表示页面为共享页面(这里就不做具体介绍了)
Avail : 3 位保留位

然后我们再说回 xv6。

到目前为止我们知道 xv6 开启了 4MB 内存页大小,在 x86 架构下当通过 cr4 控制寄存器的 PSE 位打开了 4MB 分页后 MMU 内存管理单元的分页机制就会从二级分页简化位一级分页。

即虚拟地址的高 10 位仍然是一级页表项索引,但是后面的 22 位则全部变为页内偏移量(因为一页有 2^22 = 4MB 大小了嘛)。

我们来看看这个一级页表的结构

1
2
3
# Set page directory
  movl    $(V2P_WO(entrypgdir)), %eax
  movl    %eax, %cr3

通过代码我们知道页表地址是存在一个叫做 entrypgdir 的变量中了,通过文本搜索可以在 main.c 文件的最后找到这个变量,我们看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Boot page table used in entry.S and entryother.S.
// Page directories (and page tables), must start on a page boundary,
// hence the "__aligned__" attribute.  
// Use PTE_PS in page directory entry to enable 4Mbyte pages.
__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
  // Map VA's [0, 4MB) to PA's [0, 4MB)
  [0] = (0) | PTE_P | PTE_W | PTE_PS,
  // Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
  [KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};

//PAGEBREAK!
// Blank page.

将这些宏定义都转义过来我们看看这个页表的样子

1
2
3
4
unsigned int entrypgdir[1024] = {
    [0] = 0 | 0x001 | 0x002 | 0x080,  // 0x083 = 0000 1000 0011
    [0x80000000 >> 22] = 0 | 0x001 | 0x002 | 0x080  // 0x083
};

可见这个页表非常简单,只有两个页表项 0x000000000x80000000,而且两个页表项索引的内存物理地址都是 0 ~ 4MB,其他页表项全部未作设置。而且通过这两个页表项的值也可以清楚的看出这段基地址为 0 的 4MB 大小的内存页还是特级权限内存页(低 12 位的控制位对应关系已经附在上面解释控制位的示意图里了)。

不难想象这么简单的页表肯定不是 xv6 最终使用的页表。这里可以先剧透一下,这确实只是一个临时页表,它只保证内核在即将打开内存分页支持后内核可以正常执行接下来的代码,而内核在紧接着执行 main 方法时会马上再次重新分配新的页表,而且最终的页表是 4KB 单位页面的精细页表哦~

开启内存分页机制

我们在上上篇《【学习xv6】从实模式到保护模式》讲到如何打开保护模式时其实就已经介绍过开启分页机制的方法了:将 cr0 寄存器的第 31 位置为 1。

这里还要在提一句,至此我们开启了内存分页机制,接下来内核的代码执行和数据读写都要经过 MMU 通过分页机制对内核指令地址和数据地址的转换,那么目前的页表是如何保证在转换后的物理地址是正确的,如何保证内核可以继续正常运行的呢?

我们来分析一下。

根据 kernel.ld 链接器脚本的设定,内核的虚拟地址起始于 0x80100000 即内核代码段的起始处,而内核的代码段被放置在内存物理地址 0x100000 处。我们刚刚看到目前的临时页表将虚拟地址 0x80000000 映射到物理内存的 0x0 处,所以我们来尝试用刚刚了解到的内存分页机制来解析一下 0x80100000 虚拟地址最后转换成物理地址是多少。

1
2
3
4
5
6
7
8
9
10
11
0x80100000 = 1000 0000 00|01 0000 0000 0000 0000 0000

0x80100000  10  = 1000 0000 00 = 512

0x80100000  22  = 01 0000 0000 0000 0000 0000 = 1048576

索引 512 对应  entrypgdir[ 0x80000000 >> 22 ] 即基地址为 0x0

换算的物理地址 = 0 + 1048576 = 1048576 = 0x100000

即内核代码段所在内存物理地址 0x100000

说白了就是通过页表将内核高端的虚拟地址直接映射到内核真实所在的低端物理内存位置。

这样虽然保证了在分页机制开启的情况下内核也可以正常运行,但也限制了内核最多只能使用 4MB 的内存,不过对于现在的内核来说 4MB 足够了。

设置内核栈顶位置并跳转到 main 执行

1
2
3
4
5
6
7
8
9
10
11
  # Set up the stack pointer.
  movl $(stack + KSTACKSIZE), %esp

  # Jump to main(), and switch to executing at
  # high addresses. The indirect call is needed because
  # the assembler produces a PC-relative instruction
  # for a direct jump.
  mov $main, %eax
  jmp *%eax

.comm stack, KSTACKSIZE

这里通过 .comm 在内核 bbs 段开辟了一段 KSTACKSIZE = 4096 = 4KB 大小的内核栈并将栈顶设置为这段数据区域的末尾处(栈是自上而下的嘛),最后通过 jmp 语句跳转到 main 方法处继续执行。

看到 main 这个单词玩过 C 语言的会觉得亲切吧。没错,我们即将踏入 C 语言的天地了。顺带提一句,看过这篇之后你应该能想明白为什么 main 函数会是 C 语言编写的程序的入口(链接器脚本),可不可以用别的函数做 C 语言编写程序的入口呢?(可以,通过链接器脚本)。

内核运行

我们来到 main.c 文件的 main 函数处,这里很干净的调用了一连串的方法。

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
// Bootstrap processor starts running C code here.
// Allocate a real stack and switch to it, first
// doing some setup required for memory allocator to work.
int
main(void)
{
  kinit1(end, P2V(4*1024*1024)); // phys page allocator
  kvmalloc();      // kernel page table
  mpinit();        // collect info about this machine
  lapicinit();
  seginit();       // set up segments
  cprintf("\ncpu%d: starting xv6\n\n", cpu->id);
  picinit();       // interrupt controller
  ioapicinit();    // another interrupt controller
  consoleinit();   // I/O devices & their interrupts
  uartinit();      // serial port
  pinit();         // process table
  tvinit();        // trap vectors
  binit();         // buffer cache
  fileinit();      // file table
  iinit();         // inode cache
  ideinit();       // disk
  if(!ismp)
    timerinit();   // uniprocessor timer
  startothers();   // start other processors
  kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()
  userinit();      // first user process
  // Finish setting up this processor in mpmain.
  mpmain();
}

至此我们已经了解一台 PC 从加电启动开始如何从实模式到保护模式、内存寻址如何从分段式到分页式,启动方式如何从 BIOS 到引导区程序再从引导区程序加载内核到内存中运行。

即便写了这么多,内核这位“神秘的少女”也只是刚刚走到我们面前,我们还未揭开这位“神秘的少女”的面纱去窥探她美丽的容貌。不过我们距离这一刻已经非常非常的近了,接下来我们将会看到 xv6 的内核是如何实现内存管理、进程管理、IO 操作等现代化操作系统所应该实现的诸多特性。

让我们继续加油!


在 Cocos2d-x 中使用 OpenSSL

在我们使用 Cocos2d-x 引擎制作游戏过程中经常会遇到诸如对数据进行加密、解密、MD5、SHA1 散列计算等操作的需求。对于这样的需求使用 OpenSSL 库来解决是最为方便的。下面我们就说说如何将 OpenSSL 库集成到 Cocos2d-x 项目中并在 iOS 和 Android 平台下使用。什么?!不知道 OpenSSL 是什么?这么大名鼎鼎的开源项目,移步 Wiki 去了解吧。

下载 OpenSSL 源代码

直接到 OpenSSL 开源项目官网去下载最新版本即可。我下载的是openssl-1.0.2c

编译生成 iOS 平台下适用的 OpenSSL 静态链接库

首先解压缩你下载的 OpenSSL 源代码压缩包。

1
tar -zxvf openssl-1.0.2c.tar.gz

玩过 Linux 的人都知道从源代码编译程序一般需要三步:

  1. ./Configure
  2. make
  3. make install

编译 OpenSSL 生成静态链接库的过程也一样,唯一的区别是我们要针对不同的平台架构生成针对每个平台架构的静态链接库。例如 iPhone、iPad 目前有三种架构:arm64armv7sarmv7外加模拟器的架构 i386,所以我们要重复上面三个步骤 4 次,生成四个平台架构对应的静态链接库。

这里我们偷个懒,用 GitHub 上 Raphaelios 写的 Shell 脚本来直接编译 OpenSSL ,下面我贴出脚本的源码并简单添加一些说明注释。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#!/bin/bash
#
#  Copyright (c) 2013 Claudiu-Vlad Ursache <claudiu@cvursache.com>
#  MIT License (see LICENSE.md file)
#
#  Based on work by Felix Schulze:
#
#  Automatic build script for libssl and libcrypto 
#  for iPhoneOS and iPhoneSimulator
#
#  Created by Felix Schulze on 16.12.10.
#  Copyright 2010 Felix Schulze. All rights reserved.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#  http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

# 当执行时使用到未定义过的变量,则显示错误信息
set -u

# Setup architectures, library name and other vars + cleanup from previous runs

# 四个平台架构标识
ARCHS=("arm64" "armv7s" "armv7" "i386")

# 四个平台架构分别对应的 SDK 名称
SDKS=("iphoneos" "iphoneos" "iphoneos" "macosx")

# 使用的 OpenSSL 库版本
LIB_NAME="openssl-1.0.2c"

# 临时输出目录
TEMP_LIB_PATH="/tmp/${LIB_NAME}"
LIB_DEST_DIR="lib"
HEADER_DEST_DIR="include"
rm -rf "${HEADER_DEST_DIR}" "${LIB_DEST_DIR}" "${TEMP_LIB_PATH}*" "${LIB_NAME}"

# Unarchive library, then configure and make for specified architectures
# 编译静态链接库的函数
configure_make()
{
   ARCH=$1; GCC=$2; SDK_PATH=$3;
   LOG_FILE="${TEMP_LIB_PATH}-${ARCH}.log"
   tar xfz "${LIB_NAME}.tar.gz"
   pushd .; cd "${LIB_NAME}";

   ./Configure BSD-generic32 --openssldir="${TEMP_LIB_PATH}-${ARCH}" &> "${LOG_FILE}"

   make CC="${GCC} -arch ${ARCH}" CFLAG="-isysroot ${SDK_PATH}" &> "${LOG_FILE}";
   make install &> "${LOG_FILE}";
   popd; rm -rf "${LIB_NAME}";
}

# 分别开始编译四个平台架构的静态链接库
for ((i=0; i < ${#ARCHS[@]}; i++))
do
   # 获取 SDK 路径
   SDK_PATH=$(xcrun -sdk ${SDKS[i]} --show-sdk-path)
   # 过去 gcc 编译器路径
   GCC=$(xcrun -sdk ${SDKS[i]} -find gcc)
   # 编译
   configure_make "${ARCHS[i]}" "${GCC}" "${SDK_PATH}"
done

# Combine libraries for different architectures into one
# Use .a files from the temp directory by providing relative paths
# 通过 lipo 命令将四个平台架构的静态库打包成一个静态库
create_lib()
{
   LIB_SRC=$1; LIB_DST=$2;
   LIB_PATHS=( "${ARCHS[@]/#/${TEMP_LIB_PATH}-}" )
   LIB_PATHS=( "${LIB_PATHS[@]/%//${LIB_SRC}}" )
   lipo ${LIB_PATHS[@]} -create -output "${LIB_DST}"
}
mkdir "${LIB_DEST_DIR}";
create_lib "lib/libcrypto.a" "${LIB_DEST_DIR}/libcrypto.a"
create_lib "lib/libssl.a" "${LIB_DEST_DIR}/libssl.a"

# Copy header files + final cleanups
mkdir -p "${HEADER_DEST_DIR}"
cp -R "${TEMP_LIB_PATH}-${ARCHS[0]}/include" "${HEADER_DEST_DIR}"
rm -rf "${TEMP_LIB_PATH}-*" "{LIB_NAME}"

这个脚本的用法就是将脚本和刚刚下载的 OpenSSL 源码压缩包放在同一个目录下,然后不用解压 OpenSSL 压缩包,直接运行脚本即可。

1
sh  build-openssl.sh

耐心等待片刻之后,如果没有任何报错信息,则会在脚本所在目录多出两个目录 includelib,在 lib 目录下就是我们刚刚通过脚本生成好的静态链接库 libcrypto.alibssl.a

编译生成 Android 平台下适用的 OpenSSL 静态链接库

接下来我们继续编译 Android 平台下的 OpenSSL 静态链接库。编译 Android 下的静态链接库自然要用到 NDK。所以首先要确保你下载了 NDK。我这里使用的是 android-ndk-r10d

同样根据平台架构不同 Android 下面可以生成 armarmv7x86。具体的生成步骤都是直接在命令行下执行的。

首先解压缩你的 OpenSSL 源代码压缩包并跳转至解压缩后的源代码目录.

1
2
tar -zxvf openssl-1.0.2c.tar.gz
cd openssl-1.0.2c

armv7a

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#设置你自己的 NDK 路径
export NDK=/Your Android NDK Path/android-ndk-r10d
$NDK/build/tools/make-standalone-toolchain.sh --platform=android-9 --toolchain=arm-linux-androideabi-4.6 --install-dir=`pwd`/android-toolchain-arm
export TOOLCHAIN_PATH=`pwd`/android-toolchain-arm/bin
export TOOL=arm-linux-androideabi
export NDK_TOOLCHAIN_BASENAME=${TOOLCHAIN_PATH}/${TOOL}
export CC=$NDK_TOOLCHAIN_BASENAME-gcc
export CXX=$NDK_TOOLCHAIN_BASENAME-g++
export LINK=${CXX}
export LD=$NDK_TOOLCHAIN_BASENAME-ld
export AR=$NDK_TOOLCHAIN_BASENAME-ar
export RANLIB=$NDK_TOOLCHAIN_BASENAME-ranlib
export STRIP=$NDK_TOOLCHAIN_BASENAME-strip
export ARCH_FLAGS="-march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16"
export ARCH_LINK="-march=armv7-a -Wl,--fix-cortex-a8"
export CPPFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 "
export CXXFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 -frtti -fexceptions "
export CFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 "
export LDFLAGS=" ${ARCH_LINK} "
./Configure android-armv7
PATH=$TOOLCHAIN_PATH:$PATH make

上述命令运行完成后,同样你会在 OpenSSL 源代码目录发现新生成的两个静态链接库文件libcrypto.alibssl.a。我们新建一个目录 armeabi-v7a 将两个新生成的静态链接库文件移动到这个文件夹中备用。

然后我们删除掉刚刚解压缩的 OpenSSL 源码目录,重新解压缩 OpenSSL 的源代码压缩包,准备继续编译生成另外平台架构的静态链接库。

arm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#设置你自己的 NDK 路径
export NDK=/Your Android NDK Path/android-ndk-r10d
$NDK/build/tools/make-standalone-toolchain.sh --platform=android-9 --toolchain=arm-linux-androideabi-4.6 --install-dir=`pwd`/android-toolchain-arm
export TOOLCHAIN_PATH=`pwd`/android-toolchain-arm/bin
export TOOL=arm-linux-androideabi
export NDK_TOOLCHAIN_BASENAME=${TOOLCHAIN_PATH}/${TOOL}
export CC=$NDK_TOOLCHAIN_BASENAME-gcc
export CXX=$NDK_TOOLCHAIN_BASENAME-g++
export LINK=${CXX}
export LD=$NDK_TOOLCHAIN_BASENAME-ld
export AR=$NDK_TOOLCHAIN_BASENAME-ar
export RANLIB=$NDK_TOOLCHAIN_BASENAME-ranlib
export STRIP=$NDK_TOOLCHAIN_BASENAME-strip
export ARCH_FLAGS="-mthumb"
export ARCH_LINK=
export CPPFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 "
export CXXFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 -frtti -fexceptions "
export CFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 "
export LDFLAGS=" ${ARCH_LINK} "
./Configure android
PATH=$TOOLCHAIN_PATH:$PATH make

然后我们新建一个目录 armeabi 将新生成的静态链接库文件 libcrypto.alibssl.a 移动到这个文件夹中备用。删除掉源代码目录,重新解压,继续编译生成静态链接库。

x86

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#设置你自己的 NDK 路径
export NDK=/Your Android NDK Path/android-ndk-r10d
$NDK/build/tools/make-standalone-toolchain.sh --platform=android-9 --toolchain=x86-4.6 --install-dir=`pwd`/android-toolchain-x86
export TOOLCHAIN_PATH=`pwd`/android-toolchain-x86/bin
export TOOL=i686-linux-android
export NDK_TOOLCHAIN_BASENAME=${TOOLCHAIN_PATH}/${TOOL}
export CC=$NDK_TOOLCHAIN_BASENAME-gcc
export CXX=$NDK_TOOLCHAIN_BASENAME-g++
export LINK=${CXX}
export LD=$NDK_TOOLCHAIN_BASENAME-ld
export AR=$NDK_TOOLCHAIN_BASENAME-ar
export RANLIB=$NDK_TOOLCHAIN_BASENAME-ranlib
export STRIP=$NDK_TOOLCHAIN_BASENAME-strip
export ARCH_FLAGS="-march=i686 -msse3 -mstackrealign -mfpmath=sse"
export ARCH_LINK=
export CPPFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 "
export CXXFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 -frtti -fexceptions "
export CFLAGS=" ${ARCH_FLAGS} -fpic -ffunction-sections -funwind-tables -fstack-protector -fno-strict-aliasing -finline-limit=64 "
export LDFLAGS=" ${ARCH_LINK} "
./Configure android-x86
PATH=$TOOLCHAIN_PATH:$PATH make

同样我们新建一个目录 x86 用来存放新生成的静态链接库文件 libcrypto.alibssl.a

将 OpenSSL 接入 Cocos2d-x 项目

iOS 和 Android 适用的 OpenSSL 静态链接库我们都已经编译生成了。下面看看我们怎么将 OpenSSL 静态链接库接入到 Cocos2d-x 项目中来使用。

引入 iOS 适用的 OpenSSL 静态链接库

首先这里要说明的是我使用的 Cocos2d-x 版本是 3.6。我们先在 Cocos2d-x 项目中新建一个文件夹。

1
mkdir -p Your_Cocos2d-x_Project_Path/Classes/security/openssl

然后我们将 iOS 适用的 OpenSSL 静态链接库文件 libcrypto.alibssl.a 拷贝到我们刚刚新建的目录下。

在 Xcode 中将 OpenSSL 的静态链接库文件libcrypto.alibssl.a 引入到项目中。具体做法就是在 [Build Phases] ====> [Link Binary With Libraries] 中添加对两个静态链接库的引用。

在 Xcode 中添加 OpenSSL 头文件搜索路径

然后我们再新建一个用于存放 OpenSSL 头文件的文件夹

1
mkdir -p Your_Cocos2d-x_Project_Path/Classes/security/openssl/include/openssl

将我们刚刚生成 iOS 适用的 OpenSSL 静态链接库文件时生成的 include 文件夹下面找到的所有的 .h 头文件全部复制到我们刚刚生成的目录下面。

这里需要特别注意的是包含 OpenSSL 头文件的文件夹必须叫作 openssl ,否则项目编译会不成功。

然后继续在 Xcode 中设置 OpenSSL 头文件的搜索路径。具体做法就是在 [Build Settings] ====> [User Header Search Paths] 中添加刚刚我们建立的用于存放 OpenSSL 头文件的目录的路径。要添加的路径为 Your_Cocos2d-x_Project_Path/Classes/security/openssl/include

引入 Android 适用的 OpenSSL 静态链接库

我们把刚刚用于存放 OpenSSL 静态链接库文件的 armeabi 文件夹拷贝到 Cocos2d-x 项目中来,拷贝的位置是 Android 用于存放库文件的 libs 文件夹。

1
2
cp -ivr Your_OpenSSL_Android_Path/armeabi  \
  Your_Cocos2d-x_Project_Path/proj.android/libs/

这里需要特别注意如果你的 Cocos2d-x 项目原本已经有了 armeabi 文件夹,注意不要覆盖,而是将 libcrypto.alibssl.a 文件直接放入已有的 armeabi 文件夹中即可。

在 Android.mk 文件中添加 OpenSSL 头文件搜索路径

用趁手的编辑器打开 Your_Cocos2d-x_Project_Path/proj.android/jni/Android.mk 文件,将 OpenSSL 头文件路径添加到 LOCAL_C_INCLUDES 变量中。

1
2
3
4
LOCAL_C_INCLUDES := \
  # ... Some Other Paths \
  $(LOCAL_PATH)/../../Classes/security/openssl/include \
  # ... Some Other Paths \

走个捷径

如果你觉得编译太麻烦,要编译的东西太多太复杂,或者手头没有编译环境,翻墙下载个 NDK 又太费劲巴拉巴拉……总之你不想自己编译 OpenSSL 的静态链接库,那么你可以走一个捷径。直接使用我已经编译好的文件即可。我已经把编译好的 OpenSSL 静态链接库放在 GitHub 上方便大家自取了

1
git clone git@github.com:leenjewel/openssl_for_ios_and_android.git

参考资料

如果你的 Cocos2d-x 项目编译运行成功,那么恭喜你,OpenSSL 库已经接入到你得项目中了,你已经可以调用 OpenSSL 的 API 来实现你自己的需求了。这里附上两个相关的资料供你参考:

《How-To-Build-openssl-For-iOS》

《Compiling the latest OpenSSL for Android》


【学习Xv6】加载并运行内核

前情提要

学习 xv6 系列的上一篇《【学习xv6】从实模式到保护模式》讲到我们的系统已经将计算机的 CPU 从实模式切换到保护模式状态下了,接下来我们可以暂时告别晦涩难懂的汇编语言来到 C 语言环境中了,引导的工作快要接近尾声,内核即将被载入运行。

预备知识

从硬盘读取数据

《【学习xv6】从实模式到保护模式》中我们已经讲到了如何通过向 804x 键盘控制器端口发送信号来打开 A20 gate 了,同样道理,我们向硬盘控制器的指定端口发送信号就可以操作硬盘,从硬盘读取或向硬盘写入数据。IDE 标准定义了 8 个寄存器来操作硬盘。PC 体系结构将第一个硬盘控制器映射到端口 1F0-1F7 处,而第二个硬盘控制器则被映射到端口 170-177 处。这几个寄存器的描述如下(以第一个控制器为例):

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
1F0        - 数据寄存器。读写数据都必须通过这个寄存器

1F1        - 错误寄存器,每一位代表一类错误。全零表示操作成功。

1F2        - 扇区计数。这里面存放你要操作的扇区数量

1F3        - 扇区LBA地址的0-7位

1F4        - 扇区LBA地址的8-15位

1F5        - 扇区LBA地址的16-23位

1F6 (低4位) - 扇区LBA地址的24-27位

1F6 (第4位) - 0表示选择主盘,1表示选择从盘

1F6 (5-7位) - 必须为1

1F7 (写)    - 命令寄存器

1F7 (读)    - 状态寄存器

              bit 7 = 1  控制器忙
              bit 6 = 1  驱动器就绪
              bit 5 = 1  设备错误
              bit 4        N/A
              bit 3 = 1  扇区缓冲区错误
              bit 2 = 1  磁盘已被读校验
              bit 1        N/A
              bit 0 = 1  上一次命令执行失败

稍后讲到从硬盘加载内核到内存时我们再通过 xv6 的实际代码来看看硬盘操作的具体实现。

ELF文件格式

Wiki 百科上有 ELF 文件格式的详细解释,简单的说 ELF 文件格式是 Linux 下可执行文件的标准格式。就好像 Windows 操作系统里的可执行文件 .exe 一样(当然,Windows 里的可执行文件的标准格式叫 PE 文件格式),Linux 操作系统里的可执行文件也有它自己的格式。只有按照文件标准格式组织好的可执行文件操作系统才知道如何加载运行它。我们并使使用 C 语言按照教科书写出的 HelloWorld 代码在 Linux 环境下最终通过编译器(gcc等)编译出的可以运行的程序就是 ELF 文件格式的。

那么 ELF 文件格式具体的结构是怎样的呢? 大概是下面这个样子的。

ELF 头部 ( ELF Header )
程序头表 (Program Header Table)
.text
.rodata
……
节头表 (Section Header Table)

这里我们暂时只关心 ELF 文件结构的前两个部分:ELF 头部和程序头表,xv6 源代码的 elf.h 文件中有其详细的定义,我们来看一下。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#define ELF_MAGIC 0x464C457FU  // "\x7FELF" in little endian

// ELF 文件格式的头部
struct elfhdr {
  uint magic;       // 4 字节,为 0x464C457FU(大端模式)或 0x7felf(小端模式)
                      // 表明该文件是个 ELF 格式文件

  uchar elf[12];    // 12 字节,每字节对应意义如下:
                    //     0 : 1 = 32 位程序;2 = 64 位程序
                    //     1 : 数据编码方式,0 = 无效;1 = 小端模式;2 = 大端模式
                    //     2 : 只是版本,固定为 0x1
                    //     3 : 目标操作系统架构
                    //     4 : 目标操作系统版本
                    //     5 ~ 11 : 固定为 0

  ushort type;      // 2 字节,表明该文件类型,意义如下:
                    //     0x0 : 未知目标文件格式
                    //     0x1 : 可重定位文件
                    //     0x2 : 可执行文件
                    //     0x3 : 共享目标文件
                    //     0x4 : 转储文件
                    //     0xff00 : 特定处理器文件
                    //     0xffff : 特定处理器文件

  ushort machine;   // 2 字节,表明运行该程序需要的计算机体系架构,
                    // 这里我们只需要知道 0x0 为未指定;0x3 为 x86 架构

  uint version;     // 4 字节,表示该文件的版本号

  uint entry;       // 4 字节,该文件的入口地址,没有入口(非可执行文件)则为 0

  uint phoff;       // 4 字节,表示该文件的“程序头部表”相对于文件的位置,单位是字节

  uint shoff;       // 4 字节,表示该文件的“节区头部表”相对于文件的位置,单位是字节

  uint flags;       // 4 字节,特定处理器标志

  ushort ehsize;    // 2 字节,ELF文件头部的大小,单位是字节

  ushort phentsize; // 2 字节,表示程序头部表中一个入口的大小,单位是字节

  ushort phnum;     // 2 字节,表示程序头部表的入口个数,
                    // phnum * phentsize = 程序头部表大小(单位是字节)

  ushort shentsize; // 2 字节,节区头部表入口大小,单位是字节

  ushort shnum;     // 2 字节,节区头部表入口个数,
                    // shnum * shentsize = 节区头部表大小(单位是字节)

  ushort shstrndx;  // 2 字节,表示字符表相关入口的节区头部表索引
};

// 程序头表
struct proghdr {
  uint type;        // 4 字节, 段类型
                    //         1 PT_LOAD : 可载入的段
                    //         2 PT_DYNAMIC : 动态链接信息
                    //         3 PT_INTERP : 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小
                    //         4 PT_NOTE : 指定辅助信息的位置和大小
                    //         5 PT_SHLIB : 保留类型,但具有未指定的语义
                    //         6 PT_PHDR : 指定程序头表在文件及程序内存映像中的位置和大小
                    //         7 PT_TLS : 指定线程局部存储模板
  uint off;         // 4 字节, 段的第一个字节在文件中的偏移
  uint vaddr;       // 4 字节, 段的第一个字节在内存中的虚拟地址
  uint paddr;       // 4 字节, 段的第一个字节在内存中的物理地址(适用于物理内存定位型的系统)
  uint filesz;      // 4 字节, 段在文件中的长度
  uint memsz;       // 4 字节, 段在内存中的长度
  uint flags;       // 4 字节, 段标志
                    //         1 : 可执行
                    //         2 : 可写入
                    //         4 : 可读取
  uint align;       // 4 字节, 段在文件及内存中如何对齐
};

ELF文件的加载与运行

既然 ELF 标准文件格式是可执行文件(当然不仅仅用于可执行文件,还可以用于动态链接库文件等)使用的文件格式,那么它一定是可以被加载并运行的。学习 xv6 系列的上一篇《【学习xv6】从实模式到保护模式》的预备知识中我们讲到

程序的组成我们可以简单的理解为:数据加上指令就是程序。

我们写好的程序代码经过编译器的编译成为机器码,而机器码根据其自身的作用不同被分为不同的段,其中最主要的就是代码段数据段

而一个可执行程序又是有很多个这样的段组成的,一个可执行程序可以有好几个代码段和好几个数据段和其他不同的段。当一个程序准备运行的时候,操作系统会将程序的这些段载入到内从中,再通知 CPU 程序代码段的位置已经开始执行指令的点即入口点。

既然一个可执行程序有多个代码段、多个数据段和其他段,操作系统在加载这些段的时候为了更好的组织利用内存,希望将一些列作用相同的段放在一起加载(比如多个代码段就可以一并加载),编译器为了方便操作系统加载这些作用相同的段,在编译的时候会刻意将作用相同的段安排在一起。而这些作用相同的段在程序中(ELF文件)中是如何组织的,这些组织信息就被记录在 ELF 文件的程序头表中。

所以一个 ELF 文件格式的可执行程序的加载运行过程是这样的:

  • 通过读取 ELF 头表中的信息了解该可执行程序是否可以运行(版本号,适用的计算机架构等等)
  • 通过 ELF 头表中的信息找到程序头表
  • 通过读取 ELF 文件中程序头表的信息了解可执行文件中各个段的位置以及加载方式
  • 将可执行文件中需要加载的段加载到内存中,并通知 CPU 从指定的入口点开始执行

从 bootmain 开始

学习 xv6 系列的上一篇《【学习xv6】从实模式到保护模式》的最后我们写到

通过这个跳转实际上 CPU 就会跳转到 bootasm.S 文件的 start32 标识符处继续执行了

我们打开 bootasm.S 文件看看对应的 start32 位置处的代码做了什么事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
start32:
  # Set up the protected-mode data segment registers
  # 像上面讲 ljmp 时所说的,这时候已经在保护模式下了
  # 数据段在 GDT 中的下标是 2,所以这里数据段的段选择子是 2 << 3 = 0000 0000 0001 0000
  #  16 位的段选择子中的前 13 位是 GDT 段表下标,这里前 13 位的值是 2 代表选择了数据段
  # 这里将 3 个数据段寄存器都赋值成数据段段选择子的值
  movw    $(SEG_KDATA<<3), %ax    # Our data segment selector  段选择子赋值给 ax 寄存器
  movw    %ax, %ds                # -> DS: Data Segment        初始化数据段寄存器
  movw    %ax, %es                # -> ES: Extra Segment       初始化扩展段寄存器
  movw    %ax, %ss                # -> SS: Stack Segment       初始化堆栈段寄存器
  movw    $0, %ax                 # Zero segments not ready for use  ax 寄存器清零
  movw    %ax, %fs                # -> FS                      辅助寄存器清零
  movw    %ax, %gs                # -> GS                      辅助寄存器清零

  # Set up the stack pointer and call into C.
  movl    $start, %esp
  call    bootmain

这里在初始化了一些寄存器后直接调用了一个叫做 bootmain 的函数,而这个函数是写在 bootmain.c 文件中的,终于我们暂时告别了汇编来到了 C 的世界了。来看看 bootmain 函数在做什么事情。

载入内核

bootmain.c 这个文件很小,代码很少,它其实是引导工作的最后部分(引导的大部分工作都在 bootasm.S 中实现),它负责将内核从硬盘上加载到内存中,然后开始执行内核中的程序。我们来看代码。

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
#define SECTSIZE  512  // 硬盘扇区大小 512 字节

void
bootmain(void)
{
  struct elfhdr *elf;
  struct proghdr *ph, *eph;
  void (*entry)(void);
  uchar* pa;

  // 从 0xa0000 到 0xfffff 的物理地址范围属于设备空间,
  // 所以内核放置在 0x10000 处开始
  elf = (struct elfhdr*)0x10000;  // scratch space

  // 从内核所在硬盘位置读取一内存页 4kb 数据
  readseg((uchar*)elf, 4096, 0);

  // 判断是否为 ELF 文件格式
  if(elf->magic != ELF_MAGIC)
    return;  // let bootasm.S handle error

  // 加载 ELF 文件中的程序段 (ignores ph flags).
  ph = (struct proghdr*)((uchar*)elf + elf->phoff);
  eph = ph + elf->phnum;
  for(; ph < eph; ph++){
    pa = (uchar*)ph->paddr;
    readseg(pa, ph->filesz, ph->off);
    if(ph->memsz > ph->filesz)
      stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
  }

  // Call the entry point from the ELF header.
  // Does not return!
  entry = (void(*)(void))(elf->entry);
  entry();
}

这里将内核(一个 ELF 格式文件)从硬盘读取到内存 0x10000 处的关键方法是 readseg(uchar*, uint, uint) 我们再来看看这个函数的具体实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void
readseg(uchar* pa, uint count, uint offset)  // 0x10000, 4096(0x1000), 0
{
  uchar* epa;

  epa = pa + count;  // 0x11000

  // 根据扇区大小 512 字节做对齐
  pa -= offset % SECTSIZE;

  // bootblock 引导区在第一扇区(下标为 0),内核在第二个扇区(下标为 1)
  // 这里做 +1 操作是统一略过引导区
  offset = (offset / SECTSIZE) + 1;

  // If this is too slow, we could read lots of sectors at a time.
  // We'd write more to memory than asked, but it doesn't matter --
  // we load in increasing order.
  // 一次读取一个扇区 512 字节的数据
  for(; pa < epa; pa += SECTSIZE, offset++)
    readsect(pa, offset);
}

我们来看看为什么说内核在磁盘的第二扇区,引导区在磁盘的第一扇区。在 xv6 系列文章的第一篇《【学习 Xv6 】在 Mac OSX 下运行 Xv6》里讲到过

编译成功后我们会得到 xv6.img 和 fs.img 两个文件。

在 Hardware 配置页的 Hard disk 里把 xv6.img 载入进去。

在 Advanced 配置页的 Hard disk 2 里把 fs.img 载入进去。

由此我们可以猜测内核应该在 xv6.img 这个镜像文件中。下面我们通过 Makefile 来印证这一点,我们看一下 xv6 的 Makefile 文件关于 xv6.img 构建过程的说明

1
2
3
4
xv6.img: bootblock kernel fs.img
  dd if=/dev/zero of=xv6.img count=10000
  dd if=bootblock of=xv6.img conv=notrunc
  dd if=kernel of=xv6.img seek=1 conv=notrunc

可以看出 xv6.img 是一个由 10000 个扇区组成的(512b x 10000 = 5 MB),而里面包含的只有 bootblockkernel 两个块,通过名字我们不难看出 bootblock 就是引导区,它的大小正好是 512 字节即一个磁盘扇区大小(可以通过文件浏览器看到),所以根据它们写入 xv6.img 的顺序我们证实了猜测,在 xv6 系统中引导区占一个磁盘扇区大小,放置在磁盘的第一扇区,紧随其后的是内核文件(ELF 文件格式)。我们用一个十六进制编辑器打开 kernel 文件看看,可以看到开头的数据内如如下

magic elf[12] type machine version entry phoff shoff flags
7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00 02 00 03 00 01 00 00 00 0C 00 10 00 34 00 00 00 00 F6 01 00 00 00 00 00|
ehsize phentsize phnum shentsize shnum shstrndx
34 00 20 00 02 00 28 00 12 00 0F 00

而内核文件的前 4 字节正式 ELF 文件头的模数 ELF_MAGIC 0x464C457F 这也说明了内核文件确实是一个 ELF 格式的文件。如果我们按照 ELF 文件结构重拍上面的机器码会是这样

字段名称 大小 数值 意义
magic 4字节 7F 45 4C 46 ELF 格式文件|
elf 12字节 01 01 01 00 00 00 00 00 00 00 00 00 32 位小端模式,目标操作系统为 System V
type 2字节 02 00 可执行文件|
machine 2字节 03 00 指定计算机体系架构为 x86|
version 4字节 01 00 00 00 版本号为 1|
entry 4字节 0C 00 10 00 该可执行文件入口地址|
phoff 4字节 34 00 00 00 程序头表相对于文件的起始位置是 52 字节|
shoff 4字节 00 F6 01 00 节区头表相对于文件的起始位置是 128512 字节|
flags 4字节 00 00 00 00 无特定处理器标志|
ehsize 2字节 34 00 ELF 头大小为 52 字节|
phentsize 2字节 20 00 程序头表一个入口的大小是 32 字节|
phnum 2字节 02 00 程序头表入口个数是 2 个|
shentsize 2字节 28 00 节区头表入口大小是 40 字节|
shnum 2字节 12 00 节区头表入口个数是 18 个|
shstrndx 2字节 0F 00 字符表入口在节区头表的索引是 15|

通过十六进制编辑器逐个字节的去分析内核文件的 ELF 头部是希望大家能有个更直观的认识,当然了 Linux 也为我们提供了方便的工具 readelf 命令来检查 ELF 文件的相关信息。我们再通过 readelf 命令验证一下我们刚刚通过十六进制编辑器分析的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ readelf -h kernel
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x10000c
  Start of program headers:          52 (bytes into file)
  Start of section headers:          128512 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         2
  Size of section headers:           40 (bytes)
  Number of section headers:         18
  Section header string table index: 15

最后我们看一下从磁盘读取内核到内存的方法实现,看看是怎样通过向特定端口发送数据来达到操作磁盘目的的。具体的说明请看代码附带的注释。

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
// Read a single sector at offset into dst.
// 这里使用的是 LBA 磁盘寻址模式
// LBA是非常单纯的一种寻址模式﹔从0开始编号来定位区块,
// 第一区块LBA=0,第二区块LBA=1,依此类推
void
readsect(void *dst, uint offset)      // 0x10000, 1
{
  // Issue command.
  waitdisk();
  outb(0x1F2, 1);                     // 要读取的扇区数量 count = 1
  outb(0x1F3, offset);                // 扇区 LBA 地址的 0-7 位
  outb(0x1F4, offset >> 8);           // 扇区 LBA 地址的 8-15 位
  outb(0x1F5, offset >> 16);          // 扇区 LBA 地址的 16-23 位
  outb(0x1F6, (offset >> 24) | 0xE0); // offset | 11100000 保证高三位恒为 1
                                      //         第7位     恒为1
                                      //         第6位     LBA模式的开关,置1为LBA模式
                                      //         第5位     恒为1
                                      //         第4位     为0代表主硬盘、为1代表从硬盘
                                      //         第3~0位   扇区 LBA 地址的 24-27 位
  outb(0x1F7, 0x20);                  // 20h为读,30h为写

  // Read data.
  waitdisk();
  insl(0x1F0, dst, SECTSIZE/4);
}

运行内核

内核从磁盘上载入到内存中后 bootmain 函数接下来就准备运行内核中的方法了。我们还是回到 bootmain 函数上来,请注意看我加上的注释说明。

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
void
bootmain(void)
{
  struct elfhdr *elf;
  struct proghdr *ph, *eph;
  void (*entry)(void);
  uchar* pa;

  // 从 0xa0000 到 0xfffff 的物理地址范围属于设备空间,
  // 所以内核放置在 0x10000 处开始
  elf = (struct elfhdr*)0x10000;

  // 从内核所在硬盘位置读取一内存页 4kb 数据
  readseg((uchar*)elf, 4096, 0);

  // 判断是否为 ELF 文件格式
  if(elf->magic != ELF_MAGIC)
    return;  // let bootasm.S handle error

  // 加载 ELF 文件中的程序段 (ignores ph flags).
  // 找到内核 ELF 文件的程序头表
  ph = (struct proghdr*)((uchar*)elf + elf->phoff);
  // 内核 ELF 文件程序头表的结束位置
  eph = ph + elf->phnum;
  // 开始将内核 ELF 文件程序头表载入内存
  for(; ph < eph; ph++){
    pa = (uchar*)ph->paddr;
    readseg(pa, ph->filesz, ph->off);
    // 如果内存大小大于文件大小,用 0 补齐内存空位
    if(ph->memsz > ph->filesz)
      stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
  }

  // Call the entry point from the ELF header.
  // Does not return!
  // 从内核 ELF 文件入口点开始执行内核
  entry = (void(*)(void))(elf->entry);
  entry();
}

载入内核后根据 ELF 头表的说明,bootmain函数开始将内核 ELF 文件的程序头表从磁盘载入内存,为运行内核代码做着最后的准备工作。根据上一节的分析我们知道内核的 ELF 文件的程序头表紧跟在 ELF 头表后面,程序头表一共 2 个,每个 32 字节大小,一共是 64 字节,我们继续用十六进制编辑器打开 kernel 内核二进制文件看看程序头表的内容。

type off vaddr paddr filesz memsz flags align
01 00 00 00 00 10 00 00 00 00 10 80 00 00 10 00 96 B5 00 00 FC 26 01 00 07 00 00 00 00 10 00 00|
type off vaddr paddr filesz memsz flags align|
51 E5 74 64 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 07 00 00 00 04 00 00 00|
  • 程序头表 1
字段名称 大小 数值 意义
type 4字节 01 00 00 00 可载入的段|
off 4字节 00 10 00 00 段在文件中的偏移是 4096 字节|
vaddr 4字节 00 00 10 80 段在内存中的虚拟地址|
paddr 4字节 00 10 00 00 同段在文件中的偏移量|
filesz 4字节 96 B5 00 00 段在文件中的大小是 46486 字节|
memsz 4字节 FC 26 01 00 段在内存中的大小是 75516 字节|
flags 4字节 07 00 00 00 段的权限是可写、可读、可执行|
align 4字节 00 10 00 00 段的对齐方式是 4096 字节,即4kb|
  • 程序头表 2
字段名称 大小 数值 意义
type 4字节 51 E5 74 64 PT_GNU_STACK 段|
off 4字节 00 00 00 00 段在文件中的偏移是 0 字节|
vaddr 4字节 00 00 00 00 段在内存中的虚拟地址|
paddr 4字节 00 00 00 00 同段在文件中的偏移量|
filesz 4字节 00 00 00 00 段在文件中的大小是 0 字节|
memsz 4字节 00 00 00 00 段在内存中的大小是 0 字节|
flags 4字节 07 00 00 00 段的权限是可写、可读、可执行|
align 4字节 04 00 00 00 段的对齐方式是 4 字节|

同样我们再通过 readelf 命令来验证我们通过十六进制编辑器对内核 ELF 文件的程序头表的分析结果十分正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
readelf -l kernel

Elf file type is EXEC (Executable file)
Entry point 0x10000c
There are 2 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x80100000 0x00100000 0x0b596 0x126fc RWE 0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x4

 Section to Segment mapping:
  Segment Sections...
   00     .text .rodata .stab .stabstr .data .bss
   01

在预备知识里我们讲到 ELF 文件的程序头表描述了程序各个段的情况,所以我们再通过readelf命令看看内核文件都有那些段

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
readelf -S kernel
There are 18 section headers, starting at offset 0x1f600:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        80100000 001000 008111 00  AX  0   0  4
  [ 2] .rodata           PROGBITS        80108114 009114 000672 00   A  0   0  4
  [ 3] .stab             PROGBITS        80108786 009786 000001 0c  WA  4   0  1
  [ 4] .stabstr          STRTAB          80108787 009787 000001 00  WA  0   0  1
  [ 5] .data             PROGBITS        80109000 00a000 002596 00  WA  0   0 4096
  [ 6] .bss              NOBITS          8010b5a0 00c596 00715c 00  WA  0   0 32
  [ 7] .debug_line       PROGBITS        00000000 00c596 001f8c 00      0   0  1
  [ 8] .debug_info       PROGBITS        00000000 00e522 00a965 00      0   0  1
  [ 9] .debug_abbrev     PROGBITS        00000000 018e87 0026ed 00      0   0  1
  [10] .debug_aranges    PROGBITS        00000000 01b578 0003a0 00      0   0  8
  [11] .debug_loc        PROGBITS        00000000 01b918 002f30 00      0   0  1
  [12] .debug_str        PROGBITS        00000000 01e848 000cdc 01  MS  0   0  1
  [13] .comment          PROGBITS        00000000 01f524 00001c 01  MS  0   0  1
  [14] .debug_ranges     PROGBITS        00000000 01f540 000018 00      0   0  1
  [15] .shstrtab         STRTAB          00000000 01f558 0000a5 00      0   0  1
  [16] .symtab           SYMTAB          00000000 01f8d0 0023d0 10     17 138  4
  [17] .strtab           STRTAB          00000000 021ca0 0012d0 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

结合这两次 readelf 命令的输出我们不难看出,内核文件的 ELF 程序头表中只有第一个是需要被加载的,而这个程序头表指出的加载位置 0x80100000 和内核程序的代码段 .text 的位置是一样的。

而要加载的段是 .text .rodata .stab .stabstr .data .bss ,这些段在内存中的大小总和是0x008111 + 0x000672 + 0x000001 + 0x000001 + 0x002596 + 0x00715c = 0x73335 按照对齐要求 0x1000 对齐后为 0x75516 和 ELF 程序头表中的内存大小信息一致(这里特别感谢@徐正伦同学的指正0x008111 + 0x000672 + 0x000001 + 0x000001 + 0x002596 + 0x00715c = 73335 即 0x11e77 按照对齐要求 0x1000 对齐后为 75516 即 0x000126fc(注意大小端转换,FC 26 01 00 是按照小端排列的,转换成正常的十六进制数为 0x000126fc) 和 ELF 程序头表中的内存大小信息一致。

我们再算算这些段在文件中的大小,由于这些段在文件中是顺序排列的,所以用 .bss段 的文件偏移量减去 .text段 的文件偏移量 0x00c596 - 0x001000 = 46486 这也是和 ELF 程序头表中段在文件中大小的信息一致。

内核加载后的系统内存布局

至此内核已经被载入内存并准备投入运行了。在结束这一篇前我们再看一眼目前状态下系统整体的内存布局,对即将运行的内核环境有一个大致的了解。我们来看几个关键点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// bootmain.c

void
bootmain(void)
{
  struct elfhdr *elf;
  struct proghdr *ph, *eph;
  void (*entry)(void);
  uchar* pa;

  // 从 0xa0000 到 0xfffff 的物理地址范围属于设备空间,
  // 所以内核放置在 0x10000 处开始
  elf = (struct elfhdr*)0x10000;

  // 从内核所在硬盘位置读取一内存页 4kb 数据
  readseg((uchar*)elf, 4096, 0);

  // 省略后面的代码......
}

由此可知内核被放置在 0x10000 处开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# bootasm.S

.code32  # Tell assembler to generate 32-bit code now.
start32:
  # Set up the protected-mode data segment registers
  # 像上面讲 ljmp 时所说的,这时候已经在保护模式下了
  # 数据段在 GDT 中的下标是 2,所以这里数据段的段选择子是 2 << 3 = 0000 0000 0001 0000
  #  16 位的段选择子中的前 13 位是 GDT 段表下标,这里前 13 位的值是 2 代表选择了数据段
  # 这里将 3 个数据段寄存器都赋值成数据段段选择子的值
  movw    $(SEG_KDATA<<3), %ax    # Our data segment selector  段选择子赋值给 ax 寄存器
  movw    %ax, %ds                # -> DS: Data Segment        初始化数据段寄存器
  movw    %ax, %es                # -> ES: Extra Segment       初始化扩展段寄存器
  movw    %ax, %ss                # -> SS: Stack Segment       初始化堆栈段寄存器
  movw    $0, %ax                 # Zero segments not ready for use  ax 寄存器清零
  movw    %ax, %fs                # -> FS                      辅助寄存器清零
  movw    %ax, %gs                # -> GS                      辅助寄存器清零

  # Set up the stack pointer and call into C.
  movl    $start, %esp            # 栈顶被放置在 0x7C00 处,即 $start
  call    bootmain

由此可知在执行 bootmain.c 之前 bootasm.S 汇编代码已经将栈的栈顶设置在了 0x7C00 处。之前我们了解过 x86 架构计算机的启动过程,BIOS 会将引导扇区的引导程序加载到 0x7C00 处并引导 CPU 从此处开始运行,故栈顶即被设置在了和引导程序一致的内存位置上。我们知道栈是自栈顶开始向下增长的,所以这里栈会逐渐远离引导程序,所以这里这样安置栈顶的位置并无什么问题。

最后放一张简单的内存布局示意图

1
2
3
4
5
6
0x00000000
+------------------------------------------------------------------------—+
|        0x7c00      0x7d00         0x10000                               |
|        |  引导程序  |                |    内核                          |
+-------------------------------------------------------------------------+
                                                                 0xffffffff

Android 平台用 Gprof 给 Cocos2d-x 做性能分析

gprof

在 iOS 平台下我们可以用 Xcode 自带的 Profile 工具来测试我们程序的性能,那在 Android 平台下面要怎么搞呢?答案就是gprof。什么是 gprof 呢?引用 Wiki 的解释:

Gprof is a performance analysis tool for Unix applications. It uses a hybrid of instrumentation and sampling[1] and was created as extended version of the older “prof” tool. Unlike prof, gprof is capable of limited call graph collecting and printing.

因为 Android 本来就是基于 Linux 的,所以这里用 gprof 来做性能测试是没什么问题的。不过需要注意的是,这里所说的性能测试是针对 NDK 编译的 C++ 代码的。就想 Cocos2d-x 这样的 C++ 实现的游戏引擎就可以通过 gprof 来分析。下面我们来说说搞法。

环境

我是 Mac OS X 下,这里要做性能分析的 Cocos2d-x 项目是基于 Cocos2d-x 3.2 引擎,项目本身是基于 Lua 脚本编写的。其实这些都无关紧要,只不过是编译出的 so 文件有所不同罢了。只要是 NDK 的代码都可以用 gprof 来做性能分析的。

android-ndk-profiler

要想生成 gprof 的性能分析报告,我们优先要把一个叫做 android-ndk-profiler 的模块集成到我们的项目中。android-ndk-profiler 模块的源代码在 GitHub 上面,首先要把模块代码 clone 下来

1
git clone git@github.com:richq/android-ndk-profiler.git

android-ndk-profiler 的项目自带了一篇文档说明教授如何集成和使用,但是写的比较简单,我来详细的说一下。android-ndk-profiler 项目 clone 下来后进到项目目录可以看到如下结构

1
2
3
4
 |-docs
 |-example
 |-jni
 |-test

而我们需要的就是jni这个目录下面的文件。

集成 android-ndk-profiler 到 Cocos2d-x 项目

拷贝文件

来到我们自己的 Cocos2d-x 项目目录中,新建一个叫做 android-ndk-profiler 的文件夹,将刚刚克隆的 android-ndk-profile 模块的 jni 目录中的所有文件拷贝到我们刚刚建立的文件夹中。

编辑 Android.mk 文件

打开 proj.android/jin/Android.mk 文件,加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#*注意* YOUR_ANDROID_NDK_PROFILER_PATH 是你 cocos2d-x 项目中 android-ndk-profiler 目录的位置

$(call import-add-path,$(YOUR_ANDROID_NDK_PROFILER_PATH))

# 加入头文件
LOCAL_C_INCLUDES += $(YOUR_ANDROID_NDK_PROFILER_PATH)

APP_DEBUG := $(strip $(NDK_DEBUG))

# 如果是 Debug 模式,则引入 android-ndk-profiler
ifeq ($(APP_DEBUG),1)
  LOCAL_CFLAGS := -pg
  LOCAL_STATIC_LIBRARIES += android-ndk-profiler
endif

ifeq ($(APP_DEBUG),1)
  $(call import-module,YOUR_ANDROID_NDK_PROFILER_PATH)
endif

这里只解释两点。

LOCAL_CFLAGS := -pg 通过在编译使用 -pg 编译和链接选项,gcc 在你应用程序的每个函数中都加入了一个名为mcount ( or “_mcount” , or “__mcount” , 依赖于编译器或操作系统) 的函数,也就是说你的应用程序里的每一个函数都会调用 mcount, 而 mcount 会在内存中保存一张函数调用图,并通过函数调用堆栈的形式查找子函数和父函数的地址。这张调用图也保存了所有与函数相关的调用时间,调用次数等等的所有信息。

APP_DEBUG 这里增加了一个判断,只有当以 NDK_DEBUG=1 的 Debug 模式编译 NDK 代码的时候才开启 android-ndk-profiler 分析功能,这样保证我们出 Release 版本的时候不引入性能分析。

使用 android-ndk-profiler

以 Debug 模式重新编译一下项目代码:

1
2
cd proj.android
ndk-build NDK_DEBUG=1

如果编译成功那么说明 android-ndk-profiler 已经成功集成到我们的 Cocos2d-x 项目中了,集成的过程非常简单,同样,android-ndk-profiler 的使用也非常的方便。

编辑 AppDelegate.cpp 文件

只需要引入一个头文件,添加两个函数调用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 引入头文件
#if (COCOS2D_DEBUG>0 && CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
#include "prof.h"
#endif

bool AppDelegate::applicationDidFinishLaunching()
{
#if (COCOS2D_DEBUG>0 && CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
  monstartup("libcocos2dlua.so");
#endif
  // 其他已有逻辑代码......
}

void AppDelegate::applicationDidEnterBackground()
{
  // 其他已有逻辑代码......
#if (COCOS2D_DEBUG>0 && CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
    moncleanup();
#endif
}

这里只需要注意两点。

AndroidManifest.xml 因为要生成性能分析报告,所以要赋予你的 Android 程序 WRITE_EXTERNAL_STORAGE 权限,即

1
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

libcocos2dlua.so 这个 so 文件会根据你的 Cocos2d-x 项目的类型不同名字上会有所不同,比如我们是 Lua 项目,所以 NDK 编译生成的 so 文件就叫 libcocos2dlua.so ,具体的文件名请自行到 proj.android/libs/armeabi 目录下查看。

再次以 Debug 模式重新编译一下项目代码,如果没有错误,那么大功就告成了。

生成 gmon.out 性能分析报告

项目编译完成后生成 apk 文件,将 apk 文件安装到 Android 设备上。通过上一小节我们对 AppDelegate.cpp 文件的修改不难看出,当程序在 Android 设备上运行的时候,调用了 monstartup 函数开始性能分析,当程序退到后台时调用了 moncleanup 函数生成性能分析报告。性能分析报告文件默认存储到 Android 设备的 /sdcard/gmon.out 位置,我们用 adb 工具可以把文件拉到电脑上面。

1
adb pull /sdcard/gmon.out .

当然官方文档里面也提了,如果想要自定义性能分析报告存放的位置,可以在调用 moncleanup 函数前指定要保存的位置。

1
2
setenv("CPUPROFILE", "/data/data/com.example.application/files/gmon.out", 1);
moncleanup();

解读性能分析报告 gmon.out

生成的性能分析告报 gmon.out 是不能直接通过文本编辑器打开来读的,它是个二进制文件,需要专门的工具来生成可读的文本文件。这个工具在 NDK 中已经提供了,以我使用的 android-ndk-r10d 为例:

1
2
3
4
5
cd android-ndk-r10d/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/

./arm-linux-androideabi-gprof \
  你的项目路径/proj.android/obj/local/armeabi/libcocos2dlua.so\
  你的gmon.out存放路径/gmon.out > gmon.txt

这里只解释一点。

libcocos2dlua.so 细心的读者发现这里使用的 so 文件并不是之前的那个放在 proj.android/libs/armeabi/libcocos2dlua.so 下面的那个 so 文件。这是因为最终随 apk 一起打包的那个 libcocos2dlua.so 文件(也就是 proj.android/libs/armeabi 目录下的)是不包含符号表的,而存放在 proj.android/obj/local/armeabi 目录下的是带符号表的版本。而什么是符号表,这是一个编译链接中的概念,请自行 Google 一下,或者读一读《程序员的自我修养》这本书,再次强烈推荐这本书。

gmon.txt 解读

我节选一下生成的 gmon.txt 的两处比较重要的部分来看

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
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 19.14      1.16     1.16                             png_read_filter_row_paeth_multibyte_pixel
 15.35      2.09     0.93                             cocos2d::Image::premultipliedAlpha()
 14.36      2.96     0.87                             cocos2d::Texture2D::convertRGBA8888ToRGBA4444(unsigned char const*, int, unsigned char*)
  7.10      3.39     0.43                             profCount
  3.96      3.63     0.24                             png_read_filter_row_up
  3.30      3.83     0.20                             llex
  3.14      4.02     0.19                             png_read_filter_row_sub
  2.81      4.19     0.17                             __gnu_mcount_nc

  ......

  
           Call graph (explanation follows)


granularity: each sample hit covers 2 byte(s) for 0.17% of 5.99 seconds

index % time    self  children    called     name
                                                 <spontaneous>
[1]     19.4    1.16    0.00                 png_read_filter_row_paeth_multibyte_pixel [1]
-----------------------------------------------
                                                 <spontaneous>
[2]     15.5    0.93    0.00                 cocos2d::Image::premultipliedAlpha() [2]
-----------------------------------------------
                                                 <spontaneous>
[3]     14.5    0.87    0.00                 cocos2d::Texture2D::convertRGBA8888ToRGBA4444(unsigned char const*, int, unsigned char*) [3]
-----------------------------------------------
                                                 <spontaneous>
[4]      7.2    0.43    0.00                 profCount [4]
-----------------------------------------------
                                                 <spontaneous>
[5]      4.0    0.24    0.00                 png_read_filter_row_up [5]
-----------------------------------------------

简单解释一下含义

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
Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 函数     程序       函数       函数    函数     函数      函数名
 消耗     累计       本身       调用    平均     平均
 时间     执行       执行       次数    执行     执行
 占程     时间       时间              时间     时间
 序运                                 (      (
 行时                                         
 间的                                         
 百分                                         
                                            
                                             
                                             )
                                      )


           Call graph (explanation follows)


granularity: each sample hit covers 2 byte(s) for 0.17% of 5.99 seconds

index % time    self  children    called     name
 索引    函数     函数   函数的       被调用      函数名
       执行     本身   子函数        次数
        时间     执行    执行
        占程     时间    时间
        序运
        行时
        间百
        分比


iOS 平台 Cocos2d-x 项目接入新浪微博 SDK 的坑

最近在做一个 iOS 的 cocos2d-x 项目接入新浪微博 SDK 的时候被“坑”了,最后终于顺利的解决了。发现网上也有不少人遇到一样的问题,但是能找到的数量有限的解决办法写得都不详细,很难让人理解,我来深入的写一写。

我的开发环境

  • Mac OS X 10.10.1

  • Xcode 6.1.1 (6A2008a)

  • Cocos2d-x 3.2

  • 新浪微博 SDK for iOS 2015 年 1 月 5 日从 github clone 的版本

遇到的问题

根据新浪微博 SDK 附带的文档接入项目后,在模拟器运行项目,在调用注册方法时发生崩溃。注册方法代码:

1
[WeiboSDK registerApp: @"xxxxxxxx"];

崩溃信息打印如下:

1
[__NSDictionaryM weibosdk_WBSDKJSONString] : unrecognized selector sent to instance 0x170255780

解决问题遇到的阻碍

新浪微博 SDK 附带的文档中有这么一个说明:

在工程中引入静态库之后,需要在编译时添加   –ObjC   编译选项,避免静态库中类 加载   不全造成程序崩溃。方法:程序   Target->Buid   Settings->Linking   下   Other   Linker  Flags   项添加-ObjC

在网上看到遇到同样崩溃错误的人有提到在编译时添加 -all_load 编译选项时也可以解决问题。方法也是在   Target->Buid   Settings->Linking   下   Other   Linker  Flags   项添加-all_load

无独有偶,我在打开新浪微博 SDK 附带的 Demo 项目时发现这个项目的编译选项也是-all_load而不是它自己文档所提示的-ObjC。而且在同样的开发环境下,我的 cocos2d-x 项目会崩溃,但是新浪微博 SDK 附带的 Demo 可以正常工作,想必上述两个解决方案应该是正解

但是在给自己的 cocos2d-x 项目添加了编译选项后,再次编译运行就发生了错误,错误信息如下:

1
2
3
4
5
6
7
8
Undefined symbols for architecture i386:
  "_GCControllerDidConnectNotification", referenced from:
      -[GCControllerConnectionEventHandler observerConnection:disconnection:] in libcocos2dx iOS.a(CCController-iOS.o)
  "_GCControllerDidDisconnectNotification", referenced from:
      -[GCControllerConnectionEventHandler observerConnection:disconnection:] in libcocos2dx iOS.a(CCController-iOS.o)
  "_OBJC_CLASS_$_GCController", referenced from:
      objc-class-ref in libcocos2dx iOS.a(CCController-iOS.o)
     (maybe you meant: _OBJC_CLASS_$_GCControllerConnectionEventHandler)

无论是设置成-ObjC还是-all_load编译都会失败,都会报上述找不到符号的链接错误。

正确的解决办法

这里先给出正确的解决办法再谈谈为什么要这么做。正确的做法还是设置 Other Linker Flags 这个编译选项,只不过即不用用-ObjC也不能用-all_load,而是要用-force_load path/to/your/libWeiboSDK.a,后面跟的是新浪微博 SDK 静态链接库的确切位置。

这一切是为什么?

从编译链接说起

这里不打算过多的介绍编译链接相关的只是,但是强烈推荐一本书《程序员的自我修养》,光看正标题你可能会担心这是本没什么“正经”内容的书,至少我当初第一次看到这书名的时候就是这么认为的,但是我错了,这本书的副标题是链接、装载与库。相信我,看过这本书 N 遍之后你自会对程序从源代码编译链接到生成二进制程序的原理和过程有一个非常透彻的理解,并且更重要的是看过这本书 N 遍之后你会上升几个层次。

言归正传,一个工程的源代码最终变成二进制的可执行程序、动态链接库或静态链接库要经历这么几个过程:

1
源代码 ==[编译器]==》 汇编码 ==[汇编器]==》 对象文件 ==[链接器]==》 可执行程序、动态链接库或静态链接库

再说说符号是什么?

通俗的讲,我们在源码中写的全局变量名函数名类名在生成的*.o对象文件中都叫做符号,存在一个叫做符号表的地方。

举个例子:我们在a.c文件中写了一个函数叫foo(),然后在main.c文件中调用了foo()函数,在将源码编译生成的对象文件中a.o对象文件中的符号表里保存着foo()函数符号,并通过该符号可以定位到a.o文件中关于foo()方法的具体实现代码。

链接器在链接生成最终的二进制程序的时候会发现main.o对象文件中引用了符号foo(),而foo()符号并没有在main.o文件中定义,所以不会存在与main.o对象文件的符号表中,于是链接器就开始检查其他对象文件,当检查到a.o文件中定义了符号foo(),于是就将a.o对象文件链接进来。这样就确保了在main.c中能够正常调用a.c中实现的foo()方法了。

libWeiboSDK.a 静态链接库里有什么?

Unix 的静态链接库没什么神秘的,它就是个压缩包,和平时比较常见的 zip 或 rar 之类的压缩包一样,只不过人家是用一个叫 ar 的压缩工具压缩的而已。所以我们给它解压缩一下,看看它里面都有什么。既然是用 ar 压缩的,解压自然也要用 ar 这个工具。在命令行执行:

1
ar -x lieWeiboSDK.a

结果报错了:

1
2
ar: libWeiboSDK.a is a fat file (use libtool(1) or lipo(1) and ar(1) on it)
ar: libWeiboSDK.a: Inappropriate file type or format

这里先解释一下它为什么这么肥(fat)。在做 iOS 开发时我们都知道可以用模拟器和真机来测试我们的项目,但是这两个平台的架构是不一样的,模拟器是 i386 x86_64 架构的,而我们的设备是 armv7 arm64 架构的。当在制作静态链接库的时候也要针对不同的架构制作出针对真机和模拟器的两个静态链接库,而当我们想在自己的项目中使用静态链接库的时候,如果在模拟器上运行我们要用针对模拟器的静态库版本,用真机设备测试的时候还要切换到针对真机的静态链接库,这样一来非常的麻烦。

前面说过了静态链接库就是个压缩包,那么我们是否能将这两个静态链接库压缩成一个静态链接库这样就可以同时支持模拟器和真机设备两种架构了呢?答案是肯定的。比如我们手头有一个静态链接库的两个架构版本:libXXX.i386_x86_64.alibXXX.armv7_arm64.a,那么我们可以通过如下命令来生成一个统一的静态链接库:

1
lipo -create libXXX.i386_x86_64.a libXXX.armv7_arm64.a -output libXXX.a

这样我们就得到了一个统一版本的静态库libXXX.a,它的好处是同时支持模拟器架构和真机设备架构,缺点是它的体积变大了,也就是说它很肥(fat)

libWeiboSDK.a就是这么一个合体后的静态库,我们照样可以通过命令来验证这一点:

1
lipo -info libWeiboSDK.a

这个命令会输出:

1
Architectures in the fat file: libWeiboSDK.a are: armv7 arm64 i386 x86_64

既然是个胖子,那我们就要先给它瘦身才能解压。我们随便从里面抽出一个架构的静态链接库来,瘦身命令是:

1
lipo -thin i386 libWeiboSDK.a -output libWeiboSDK.i386.a

这样我们就把针对 i386 平台的新浪微博 SDK 静态链接库给抽离出来了,我们管它叫libWeiboSDK.i386.a,现在我们再用ar命令解压它看看里面有什么

1
ar -x libWeibo.i386.a

解压完成后你会看到好多好多以.o结尾的对象文件,回忆回忆刚刚我们讲到的编译链接过程,这些对象文件就是给链接器最终生成静态链接库时用到的文件,由于太多了,我只列出我们要讲到的几个:

1
2
3
4
5
6
7
8
9
-rw-r--r--  1 leenjewel  staff    13K Jan  8 15:47 NSData+WBSDKBase64.o
-rw-r--r--  1 leenjewel  staff    42K Jan  8 15:47 UIImage+WBSDKResize.o
-rw-r--r--  1 leenjewel  staff    12K Jan  8 15:47 UIImage+WBSDKStretch.o
-rw-r--r--  1 leenjewel  staff    74K Jan  8 15:47 UIView+WBSDKSizes.o
-rw-r--r--  1 leenjewel  staff    58K Jan  8 15:47 WBAidManager.o
-rw-r--r--  1 leenjewel  staff    15K Jan  8 15:47 WBAuthorizeRequest.o
-rw-r--r--  1 leenjewel  staff    16K Jan  8 15:47 WBAuthorizeResponse.o
-rw-r--r--  1 leenjewel  staff    19K Jan  8 15:47 WBBaseMediaObject.o
-rw-r--r--  1 leenjewel  staff   265K Jan  8 15:47 WBSDKJSONKit.o

为什么会在运行中崩溃?

当我们把新浪微博 SDK 的静态链接库引入我们自己的项目,并 Build 我们自己的项目到模拟器或真机设备上运行的过程其实也是一个编译链接的过程,最终从项目 Build 生成可以在模拟器或真机设备运行的 App,而这个过程中对新浪微博 SDK 的静态链接库的处理方式和我们刚刚拆开libWeiboSDK.a的过程差不多:

  • 将 libWeibSDK.a 根据当前所构建的平台架构(模拟器还是真机设备)进行瘦身

  • 将瘦身的静态库解压拆包

  • 将用到的对象文件链接进入项目

而我们遇到的崩溃问题恰恰是出在了将用到的对象文件链接进入项目这一步。

苹果的开发者网站针对这个问题有一篇说明文章,我们来引用一下里面的内容:

The dynamic nature of Objective-C complicates things slightly. Because the code that implements a method is not determined until the method is actually called,

这句话解释起来就是说 Objective-C 是有运行时(runtime)的,一个方法要执行什么代码是在运行时决定的,而不是在链接时决定的。想要再深入了解 Objective-C 运行时知识的,可以看看这里

Objective-C does not define linker symbols for methods. Linker symbols are only defined for classes.

因为在 Objective-C 中,一个方法的执行是要到运行时才决定的,所以在链接时,链接器只链接类的符号,并不会链接方法的符号。

For example, if main.m includes the code [[FooClass alloc] initWithBar:nil]; then main.o will contain an undefined symbol for FooClass, but no linker symbols for the -initWithBar: method will be in main.o

最后还举了一个例子:当你在main.m文件中初始化一个类FooClass的对象,然后调用了这个类FooClass的一个对象方法initWithBar,在链接器分析由main.m编译生成的main.o对象文件时,发现这个对象文件没有定义符号FooClass于是就会去其他.o对象文件中去寻找FooClass符号的定义,而至于方法符号initWithBar的定义在哪里链接器是不关心的,因为initWithBar的执行是由运行时负责的,链接器不管。

好了,现在问题来了,我们再重复一下这句话:

1
Objective-C 中方法的执行实在运行时决定的,所以链接器只链接类的符号,不链接方法的符号

我们再回过头看看崩溃的报错信息:

1
[__NSDictionaryM weibosdk_WBSDKJSONString] : unrecognized selector sent to instance 0x170255780

这说明崩溃的原因是在运行时调用__NSDictionaryM类对象的weibosdk_WBSDKJSONString方法时没有找到该方法的定义。这里不难看出__NSDictionaryMFoundation Framework中的类,而方法weibosdk_WBSDKJSONString是新浪微博 SDK 自己定义的方法,新浪在这里使用了分类技术扩展了__NSDictionaryM类的行为。我们来验证这一点:

我们已经解压出libWeiboSDK.a中的全部.o对象文件,我们用nm命令导出全部对象文件中的符号:

1
nm *.o >> libWeiboSDK.symbols.txt

然后我们用个文本编辑器打开libWeiboSDK.symbols.txt查找weibosdk_WBSDKJSONString,我们可以查到如下结果:

1
2
3
4
WBSDKJSONKit.o:
00007ba0 t -[NSArray(WBSDKJSONKitSerializing) weibosdk_WBSDKJSONString]
00007de8 t -[NSDictionary(WBSDKJSONKitSerializing) weibosdk_WBSDKJSONString]
000079cd t -[NSString(WBSDKJSONKitSerializing) weibosdk_WBSDKJSONString]

这就可以说明新浪微博 SDK 确实使用了分类技术扩展了NSArrayNSDictionaryNSString三个 Foundation Framework 下面的类的行为。好,现在可以真相大白了:

  • 在链接时,链接器发现WBSDKJSONKit.o对象文件中缺少类符号NSArrayNSDictionaryNSString

  • 链接器从Foundation Framework中找到了类的符号定义,从而将Foundation Framework中相关的对象文件链接进来

  • 由于链接器不链接方法符号,所以weibosdk_WBSDKJSONString这样的方法符号完全被忽略了。

  • 由于类符号的定义在Foundation Farmework中定义,所以WBSDKJSONKit.o对象文件中没有符号被引用,链接器就没有把这个对象文件链接进来。

  • 运行时运行到weibosdk_WBSDKJSONString方法时,由于Foundation Framework中是不存在这个方法的定义的,而存在这个方法定义的WBSDKJSONKit.o对象文件又没有被链接器链接进来,所以崩溃了。

为什么增加编译选项可以解决问题?

我们继续引用苹果的开发者网站针对这个问题的说明文章中的内容:

Passing the -ObjC option to the linker causes it to load all members of static libraries that implement any Objective-C class or category. This will pickup any category method implementations. But it can make the resulting executable larger, and may pickup unnecessary objects. For this reason it is not on by default.

加了-ObjC选项后,不管是否被引用到,链接器会把 Objective-C 的类和分类的所有对象文件全部链接,全部链接后方法符号全部被链接进来,崩溃的问题自然被解决了。

-all_load选项更彻底,这个选项会让链接器把全部的对象文件都链接进来,当然,代价就是构建的 APP 体积会变大。

为什么 cocos2d-x 加了编译选项会无法编译通过?

其实准确的说法是编译可以成功进行,链接器执行报错。我们再回顾一下加了-ObjC-all_load链接选项后链接器的报错信息:

1
2
3
4
5
6
7
8
Undefined symbols for architecture i386:
  "_GCControllerDidConnectNotification", referenced from:
      -[GCControllerConnectionEventHandler observerConnection:disconnection:] in libcocos2dx iOS.a(CCController-iOS.o)
  "_GCControllerDidDisconnectNotification", referenced from:
      -[GCControllerConnectionEventHandler observerConnection:disconnection:] in libcocos2dx iOS.a(CCController-iOS.o)
  "_OBJC_CLASS_$_GCController", referenced from:
      objc-class-ref in libcocos2dx iOS.a(CCController-iOS.o)
     (maybe you meant: _OBJC_CLASS_$_GCControllerConnectionEventHandler)

根据报错信息我们能够了解到报错是一个名叫CCController-iOS.o对象文件导致的,而这个文件对应的源代码是CCController-iOS.mm,通过阅读源码我们发现,这个文件中定义了一个 Objective-C 的类GCControllerConnectionEventHandler,这个类中的方法引用了GCControllerDidConnectNotificationGCControllerDidDisconnectNotification两个类,而这两个类实在GameController Framework中定义的。

而 cocos2d-x 生成的项目默认并没有为我们引入GameController Framework,所以在链接时由于链接器找不到对应类的符号定义,所以才会报错。如果你到 Xcode->Target->Buid Phases-> 下   Link Binary With Libraries   项添加GameController Framework就可以解决问题了,但是这种解决方式很不干净

正确的姿势

-force_load path/to/your/libWeiboSDK.a链接选项其实是干了和-ObjC-all_load一样的事情,只不过它更有针对性,它只让链接器把你指定的静态链接库中的全部对象文件链接进来,这样更清爽一些。

希望我的解释已经够深入了。

:–)


Google Play In-app Billing 踩过的那些坑

最近在做的一款游戏针对海外发行,要上 Google Play,所以支付这块儿要接入 Google Play 。因为我们是免费 App + 应用内支付,所以 Google Play 这块儿只接入 In-app 类型的支付方式,接下来我准备吐槽了。

另外又新写了一篇《Google 支付从入门到跳坑》来总结了一下,欢迎阅读。

大环境

国内做 Google Play 相关的开发外围难度因素可想而知。具体原因相比大家都知道,所以那个什么墙什么的我就不多说了。这里已经无力吐槽了。

流程

这里简单说一下 Google Play In-app Billing 支付的流程。具体的建议看官方文档最靠谱。Google Play 没有可重复购买商品这个概念,所有的“商品/充值档”用户成功购买过一次之后就不允许再次购买了。所以为了实现像应用内支付充值这种可重复购买的“商品/充值档”,Google Play 提供了一个“消耗”借口(Consuming In-app Products)。用户购买完商品后,调一下“消耗”接口,这样用户下次就可以继续购买了。

写代码

还是就看官方文档是最靠谱的,In-app Billing 的 API 有个 v2 版本和 v3 版本,v2 版本已经不支持了,直接整 v3 版本的吧。怎么开发,怎么写代码这块儿没什么好说的,看着文档写,基本都不会错。这里我只掉进坑里去一次,说说。

ProductID 这个坑

在发起购买时需要调用 getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload)这个方法,sku 就是充值档ID(也就是 productId)。因为我们的游戏是夸 iOS 和 Android 平台的,在做 iOS 支付的时候,配置的充值档ID都是 Bundle Identifier + xxx 的格式,比如:

1
2
com.abc.def.product1
com.abc.def.product2

在 Google Play Developer Console 配置充值档时,为了统一,我们配置的和 iOS 一样的 productId,也同样是为了统一,我们 Android 项目配置的 package name 也和 iOS 配置的 Bundle Identifier 是一样的,所以我就掉坑里面了。

看到 getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload) 这个方法需求一个 sku 还需求一个 packageName ,我当时错误的认为 sku 和 packageName 分开传,所以错误的写成了

1
2
3
// 假设要充值的充值档 ID 为 com.abc.def.product1 
// **注意** 这样写是错误的!!!
getBuyIntent(3, "com.abc.def", "product1", "inapp", "");

结果在调用 getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle) 方法时就一直返回空结果,告诉我找不到对应的商品。这里正确的做法就是严格按照你 Google Play Developer Console 里配置的 ProductId 来写,配置的是什么值,就传什么值。

1
2
// 假设要充值的充值档 ID 为 com.abc.def.product1 
getBuyIntent(3, "com.abc.def", "com.abc.def.product1", "inapp", "");

支付验证

一般的支付验证都是支付方会有个接口,玩家支付成功后需要将支付数据通过支付方提供的接口(一般为 HTTP 或 HTTPS)进行验证,验证通过后才会确认支付成功。

Google Play In-app Billing 并没有提供支付验证接口,它的验证方法是通过公钥自行验证计算。在客户端通过公钥自行验证虽然没什么问题,但总觉的不放心,特别是手游这种,还是发往自己的服务器端去做验证比较好。我看 Google 的官方文档对这方面的介绍并不是很多,贴别是服务器端验证,这里我贴出 PHP 的范例代码,其实挺简单的。

通过参看官方文档对 getBuyIntent 支付成功返回的数据结构的说明:

Table 3. Response data from an In-app Billing Version 3 purchase request.

Key : INAPP_PURCHASE_DATA

Description : A String in JSON format that contains details about the purchase order. See table 4 for a description of the JSON fields.

Key : INAPP_DATA_SIGNATURE

Description : String containing the signature of the purchase data that was signed with the private key of the developer. The data signature uses the RSASSA-PKCS1-v1_5 scheme.

当客户端收到玩家支付完成的回调时,将上述两个数据传送给后端服务器接口,后端的验证流程是:

在 Google Play Developer Console 找到当前应用的设置页面,在“服务和API”设置分页内找到“此应用的许可密钥”,将密钥原封不动且删除多余空格地复制下来,然后我们直接上 PHP 的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
$inapp_purchase_data = '客户端回传的 INAPP_PURCHASE_DATA 对应的数据';
$inapp_data_signature = '客户端回传的 INAPP_DATA_SIGNATURE 对应的数据';
$google_public_key = 'Google Play Developer Console 中此应用的许可密钥';

$public_key = "-----BEGIN PUBLIC KEY-----\n" . chunk_split($google_public_key, 64, "\n") . "-----END PUBLIC KEY-----";

$public_key_handle = openssl_get_publickey($public_key);

$result = openssl_verify($inapp_purchase_data, base64_decode($inapp_data_signature), $public_key_handle, OPENSSL_ALGO_SHA1);

if (1 === $result) {
    // 支付验证成功!
}

测试

总的来说,Google Play In-app Billing 支付接入的开发算是比较简单的,步骤不多,也比较容易理解。最让人头疼的是测试,特别是你人在大陆,那就是难上加难了~坑略多。

上传测试 APK

首先你要在 Google Play Developer Console 里面为你要测试的 APP 新建一个应用,然后上传你要测试的 APP 的 APK 包。这里有两点注意:

  • 上传的 APK 包必须要有签名,而且不能用 Debug 签名。
  • 上传的 APK 包体积不能超过 50MB 超过的话要做分包(分包打算下回单开一篇来讲)。

Google Play Developer Console 一个应用可以对应发布三个频道,正式版、Beta版和Alpha版,我们测试用的 APK 只要上传到 Beta版或 Alpha版频道就好。

发布你的应用

看到“发布”这个词你可能会慌一下:“怎么,我的应用还没做完呢,怎么能发布呢?”。不要担心,这里你只上传了你的测试 APK 包到 Beta 或 Alpha 频道,把应用发布了,普通用户也是无法下载的。发布是必须做的,如果你只处于默认的“草稿”状态,是根本没办法测试支付功能的。

这里吐个槽,当你发布了你的应用后 Google Play 不会立即让它生效。仔细看你的 Google Play Developer Console 页面,你会发现 Google 提示你要等一等才会发布成功,等待的时间是按“小时”为单位的,没辙,耐心等待吧。

准备测试帐号

上面说了,发布应用后要等,到底要等到什么时候呢?在你的 Google Play Console 页面你对应发布的频道那里会有个“管理测试人员列表”的超链接,点开会弹出一个弹出框,在弹出框里有个标题是“与测试者分享以下链接”,下面有个 URL 链接,形如:

1
https://play.google.com/apps/testing/xxxxxxx

的链接,用浏览器点开这个链接你会发现如果它跳转到了 Google Play 应用商店并能看到你的测试应用了,说明你已经发布成功了。

什么?你一直看不到 Google Play 应用商店里你发布的应用?那说明你当前登录到 Google Play 应用商店的帐号既不是你这个应用的开发者帐号,也不是你这个应用的测试组帐号。

成为开发者需要在 Google Play Developer Console 里面设置,这个就不多讲了。主要提一下怎么成为测试帐号。

首先你要到 Google Group 去建立一个新的论坛,然后回到 Google Play Console 页面你对应发布的频道,还是点击“管理测试人员列表”,在弹出的弹出框里将你刚刚建立好的 Google Group 群组的 Email 填写进去。这样只要你邀请进入这个 Google Group 的人员都是这个应用的测试人员了。

在真机上安装要测试的APP

要测试 Google Play In-app Billing 支付,一定要在真实的设备上测试,而且还要保证设备上装了 Google Play 国内一般装了制定 Android 系统的手机都不会默认安装 Google Play 需要你去网上搜一艘 Google Play 的安装包。我当时用的是 Google 的 Nexus 7 测试的,系统用的 Google 原生 4.4.4。

这里你可能还有个疑问:“我在测试我的 APP 时肯定会经常做一些修改,或要加断点 Debug,我总不能修改一次就发个 APK 包到 Google Play Developer Console 吧?”。这里你可以放心,你完全没必要用上传的 APK 来测试,你只要保证

安装到真机上的测试 APP 签名和上传到 Google Play 的 APK 包的签名一致

搞定 Google Play

好了,如果目前你手拿着安装好测试 APP 的真机设备,设备上安装有 Google Play ,Google Play 上登录了你的开发组或测试组人员帐号,你的应用已经成功发布了,而刚好你此时人不是在大陆,那么恭喜你,你已经可以开始测试你的支付了。

如果你上述工作都做好了,可是你人在大陆,那么你就“万事俱备,只欠东风”了。

先吐槽,在 Google Play Developer Console 的应用发布国家列表中是不允许选择“中国”的,Google Play 在大陆也是不允许支付的。如果你用你的设备打开 Google Play 应用商店看到的满眼都是免费应用,一个付费应用都没有,那么“恭喜”你,目前 Google Play 认为你是个大陆用户,你是不允许付费的,自然你就没办法测试你的支付流程了。

网上查一圈,发现不少人给出解决方案,都挺复杂的。什么又要先把设备给越狱拿到 root 权限啦,什么又要安装第三方破解 Google Play 的软件啦,还有什么需要插个国外的 SIM 卡了,据本人亲测根本没那么那么费劲,你只要有个 VPN 即可,你懂的。

根据我本人拿着手头的 Nexus 7 亲测,只要你在设备上连上 VPN(也有人说要 VPN 对应的国家要涵盖在你要测试应用对应发布的国家范围内,这点我没有亲测,我只知道当时我的 VPN 是加拿大,而加拿大也是在我测试的应用对应的发布国家内)。再打开 Google Play 应用商店,如果这时候你发现你能看到付费应用了,这说明你的“东风”也来了。

如果连上 VPN 后在 Google Play 应用商店还是看不到其他付费应用的话,先尝试去设置那里删除 Google Play 的缓存数据,如果还不行据说需要将你的设备恢复一下出厂设置再连上 VPN 就可以了。

打开你的测试 APP 点击支付,如果弹出 Google Play 的支付弹出框,说明流程都走通了。最后说一句,要想付费成功你的 Google Play 帐号必须绑定有海外支付能力的信用卡或者有海外支付能力的 Paypal 账户,这个只能你自己想办法了。

祝玩得愉快~