第3天 进入32位模式并导入C语言


第3天 进入32位模式并导入C语言
https://weread.qq.com/web/reader/38732220718ff5cf3877215k37632cd021737693cfc7149

1 制作真正的IPL
到昨天为止我们讲到的启动区,虽然也称为IPL(Initial Program Loader,启动程序装载器),但它实质上并没有装载任何程序。而从今天起,我们要真的用它来装载程序了。

那么我们先从简单的程序开始吧。因为磁盘最初的512字节是启动区,所以要装载下一个512字节的内容。我们来修改一下程序。

本次添加的部分
; 读盘

        MOV        AX,0x0820
        MOV        ES,AX
        MOV        CH,0            ; 柱面0
        MOV        DH,0            ; 磁头0
        MOV        CL,2            ; 扇区2

        MOV        AH,0x02            ; AH=0x02 : 读盘
        MOV        AL,1            ; 1个扇区
        MOV        BX,0
        MOV        DL,0x00            ; A驱动器
        INT        0x13            ; 调用磁盘BIOS
        JC        error
新出现的指令只有JC。真好,讲起来也轻松了。所谓JC,是“jump if carry”的缩写,意思是如果进位标志(carry flag)是1的话,就跳转。这里突然冒出来“进位标志”这么个新词,不过大家不用担心,很快就会明白的。

int13
磁盘读、写,扇区校验(verify),以及寻道(seek)        
AH=0x02;(读盘)
AH=0x03;(写盘)
AH=0x04;(校验)
AH=0x0c;(寻道)
AL=处理对象的扇区数;(只能同时处理连续的扇区)
CH=柱面号 &0xff;
CL=扇区号(0-5位)|(柱面号&0x300)>>2;
DH=磁头号;
DL=驱动器号;
ES:BX=缓冲地址;(校验及寻道时不使用)
返回值:
FLACS.CF==0:没有错误,AH==0
FLAGS.CF==1:有错误,错误号码存入AH内(与重置(reset)功能一样)
我们这次用的是AH=0x02,哦,原来是“读盘”的意思。

返回值那一栏里的FLAGS.CF又是什么意思呢?这就是我们刚才讲到的进位标志。也就是说,调用这个函数之后,如果没有错,进位标志就是0;如果有错,进位标志就是1。这样我们就能明白刚才为什么要用JC指令了。

其他几个寄存器我们也来依次看一下吧。CH、CL、DH、DL分别是柱面号、扇区号、磁头号、驱动器号,一定不要搞错。在上面的程序中,柱面号是0,磁头号是0,扇区号是2,磁盘号是0

与光盘不同,软盘磁盘是两面都能记录数据的。因此我们有正面和反面两个磁头,分别是磁头0号和磁头1号

按照整个圆环为单位读写的话,实在有点多,所以我们又把这个圆环均等地分成了几份。软盘分为18份,每一份称为一个扇区

综上所述,1张软盘有80个柱面,2个磁头,18个扇区,且一个扇区有512字节。所以,一张软盘的容量是:80×2×18×512=1474 560 Byte=1440KB

含有IPL的启动区,位于C0-H0-S1(柱面0,磁头0,扇区1的缩写),下一个扇区是C0-H0-S2。这次我们想要装载的就是这个扇区。

但一个BX只能表示0~0xffff的值,也就是只有0~65535,最大才64K
于是为了解决这个问题,就增加了一个叫EBX的寄存器,这样就能处理4G内存了。这是CPU能处理的最大内存量,没有任何问题。但EBX的导入是很久以后的事情,在设计BIOS的时代,CPU甚至还没有32位寄存器,所以当时只好设计了一个起辅助作用的段寄存器(segment register)。在指定内存地址的时候,可以使用这个段寄存器。

我们使用段寄存器时,以ES:BX这种方式来表示地址,写成“MOV AL,[ES:BX]”,它代表ES×16+BX的内存地址。我们可以把它理解成先用ES寄存器指定一个大致的地址,然后再用BX来指定其中一个具体地址。

这样如果在ES里代入0xffff,在BX里也代入0xffff,就是1114 095字节,也就是说可以指定1M以内的内存地址了。虽然这也还是远远不到64M

这次,我们指定了ES=0x0820, BX=0,所以软盘的数据将被装载到内存中0x8200到0x83ff的地方。可能有人会想,怎么也不弄个整点的数,比如0x8000什么的,那多好。但0x8000~0x81ff这512字节是留给启动区的,要将启动区的内容读到那里,所以就这样吧。

那为什么使用0x8000以后的内存呢?这倒也没什么特别的理由,只是因为从内存分布图上看,这一块领域没人使用,于是笔者就决定将我们的“纸娃娃操作系统”装载到这一区域。0x7c00~0x7dff用于启动区,0x7e00以后直到0x9fbff为止的区域都没有特别的用途,操作系统可以随便使用。

到目前为止我们开发的程序完全没有考虑段寄存器,但事实上,不管我们要指定内存的什么地址,都必须同时指定段寄存器,这是规定。一般如果省略的话就会把“DS:”作为默认的段寄存器。

以前我们用的“MOV CX, [1234]”,其实是“MOV CX, [DS:1234]”的意思。“MOV AL, [SI]”,也就是“MOV AL, [DS:SI]”的意思。在汇编语言中,如果每回都这样写就太麻烦了,所以可以省略默认的段寄存器DS。

----------
试错
软盘这东西很不可靠,有时会发生不能读数据的状况,这时候重新再读一次就行了。所以即使出那么一、两次错,也不要轻易放弃,应该让它再试几次。当然如果让它一直重试下去的话,要是磁盘真的坏了,程序就会陷入死循环,所以我们决定重试5次,再不行的话就真正放弃。改良后的程序就是projects/03_day下的harib00b。

还是从新出现的指令开始讲吧。JNC是另一个条件跳转指令,是“Jump if notcarry”的缩写。也就是说进位标志是0的话就跳转。JAE也是条件跳转,是“Jumpif above or equal”的缩写,意思是大于或等于时跳转。

现在说说出错时的处理。重新读盘之前,我们做了以下的处理,AH=0x00,DL=0x00, INT 0x13。通过前面介绍的(AT)BIOS的网页我们知道,这是“系统复位”。它的功能是复位软盘状态,再读一次。剩下的内容都很简单,只要读一读程序就能懂。
----------
读到18扇区
我们趁着现在这劲头,再往后多读几个扇区吧。下面来看看projects/03_day下的harib00c。
; 读盘

        MOV        AX,0x0820
        MOV        ES,AX
        MOV        CH,0            ; 柱面0
        MOV        DH,0            ; 磁头0
        MOV        CL,2            ; 扇区2 
readloop:
        MOV        SI,0            ; 失敗回数   
retry:
        MOV        AH,0x02            ; AH=0x02 : 读盘
        MOV        AL,1            ; 1个扇区
        MOV        BX,0
        MOV        DL,0x00            ; A驱动器
        INT        0x13            ; 调用磁盘BIOS
        JNC        next            ;
        ADD        SI,1            ;
        CMP        SI,5            ;
        JAE        error            ; SI >= 5
        MOV        AH,0x00
        MOV        DL,0x00            ; A驱动器
        INT        0x13            ; 重置驱动器
        JMP        retry
next:
        MOV        AX,ES            ; 把内存地址后移0x20
        ADD        AX,0x0020
        MOV        ES,AX            ; 因为没有ADD ES,0x020,所以需要绕个弯
        ADD        CL,1
        CMP        CL,18
        JBE        readloop
fin:
        HLT                        ; CPUを停止
        JMP        fin                ; 無限循环
新出现的指令是JBE。这也是个条件跳转指令,是“jump if below or equal”的缩写,意思是小于等于则跳转。
程序做的事情很简单,只要读一读程序大家马上会明白。要读下一个扇区,只需给CL加1,给ES加上0x20就行了。CL是扇区号,ES指定读入地址。0x20是十六进制下512除以16的结果,如果写成“ADD AX,512/16”或许更好懂。(笔者在写的时候,直接在头脑中换算成了0x20,当然写成512/16也一样。)可能有人会说:往BX里加上512不是更简单吗?说来也是。不过这次我们想练习一下往ES里做加法的方法,所以这段程序就留在这儿吧。

可能有人会想,这里为什么要用循环呢?这个问题很好。的确,这里不是非要用循环才行,在调用读盘函数的INT 0x13的地方,只要将AL的值设置成17就行了。这样,程序一下子就能将扇区2~18共17个扇区的数据完整地读进来。之所以将这部分做成循环是因为笔者注意到了磁盘BIOS读盘函数说明的“补充说明”部分。这个部分内容摘要如下:

指定处理的扇区数,范围在0x01~0xff(指定0x02以上的数值时,要特别注意能够连续处理多个扇区的条件。如果是FD的话,似乎不能跨越多个磁道,也不能超过64KB的界限

这些内容看起来很复杂。因为很难一两句话说清楚,这里暂不详细解释,就结果而言,这些注意事项目前跟我们还没有关系,就是写成AL=17结果也是完全一样的。但这样的方式在下一次的程序中,就会成为问题,因此为了能够循序渐进,这里特意用循环来一个扇区一个扇区地读盘。


总之循环的可扩展性好,比如跨柱面读。

虽然显示的画面没什么变化,但我们已经把磁盘上C0-H0-S2到C0-H0-S18的512×17=8704字节的内容,装载到了内存的0x8200~0xa3ff处。
------
4 读入10个柱面
趁热打铁,我们继续学习下面的内容。C0-H0-S18扇区的下一扇区,是磁盘反面的C0-H1-S1,这次也从0xa400读入吧。按顺序读到C0-H1-S18后,接着读下一个柱面C1-H0-S1。我们保持这个势头,一直读到C9-H1-S18好了。现在我们就来看一看projects/03_day下的harib00d内容。

; 读盘

        MOV        AX,0x0820
        MOV        ES,AX
        MOV        CH,0            ; 柱面0
        MOV        DH,0            ; 磁头0
        MOV        CL,2            ; 扇区2 
readloop:
        MOV        SI,0            ; 失敗回数   
retry:
        MOV        AH,0x02            ; AH=0x02 : 读盘
        MOV        AL,1            ; 1个扇区
        MOV        BX,0
        MOV        DL,0x00            ; A驱动器
        INT        0x13            ; 调用磁盘BIOS
        JNC        next            ;
        ADD        SI,1            ;
        CMP        SI,5            ;
        JAE        error            ; SI >= 5
        MOV        AH,0x00
        MOV        DL,0x00            ; A驱动器
        INT        0x13            ; 重置驱动器
        JMP        retry
next:
        MOV        AX,ES            ; 把内存地址后移0x20
        ADD        AX,0x0020
        MOV        ES,AX            ; 因为没有ADD ES,0x020,所以需要绕个弯
        ADD        CL,1
        CMP        CL,18
        JBE        readloop
        MOV        CL,1
        ADD        DH,1
        CMP        DH,2
        JB        readloop        ; 磁头小于2,继续读
        MOV        DH,0
        ADD        CH,1
        CMP        CH,CYLS
        JB        readloop        ; 柱面小于10,继续读
首先还是说说新出现的指令JB。这也是条件跳转指令,是“jump if below”的缩写。翻译过来就是:“如果小于的话,就跳转。”还有一个新指令,就是在程序开头使用的EQU指令。这相当于C语言的#define命令,用来声明常数。“CYLS EQU10”意思是“CYLS = 10”。EQU是“equal”的缩写。只将它定义成常数是因为以后我们可能修改这个数字。现在我们先随意定义成10个柱面,以后再对它进行调整(CYLS代表cylinders)。

现在启动区程序已经写得差不多了。如果算上系统加载时自动装载的启动扇区,那现在我们已经能够把软盘最初的10× 2× 18× 512=184320 byte=180KB内容完整无误地装载到内存里了。
--------------
5 着手开发操作系统
将以上内容保存为haribote.nas,用nask编译,输出成haribote.sys
使用make install指令,将磁盘映像文件写入磁盘。
在Windows里打开那个磁盘,把haribote.sys保存到磁盘上
使用工具将磁盘备份为磁盘映像。

用二进制编辑器打开刚做成的映像文件“haribote.img”,看一看“haribote.sys”文件在磁盘中是什么样的。
最先注意到的地方是0x002600附近,磁盘的这个位置好像保存着文件名。
再往下看,找到0x004200那里,可以看到“F4 EB FD”
这是什么呢?这就是haribote.sys的内容。因为我们用二进制编辑器看haribote.sys,它恰好也就是这三个字节。好久没用的二进制编辑器这次又大显身手了

以上内容可以总结为:一般向一个空软盘保存文件时,
(1) 文件名会写在0x002600以后的地方 -- 19扇区--C0-H1-S1
(2) 文件的内容会写在0x004200以后的地方。-- 33扇区--C0-H1-S15
----
6 从启动区执行操作系统
那么,要怎样才能执行磁盘映像上位于0x004200号地址的程序呢?现在的程序是从启动区开始,把磁盘上的内容装载到内存0x8000号地址,所以磁盘0x4200处的内容就应该位于内存0x8000+0x4200=0xc200号地址。

这样的话,我们就往haribote.nas里加上ORG 0xc200,然后在ipl.nas处理的最后加上JMP 0xc200这个指令。这样修改后,得到的就是“projects/03_day”下的harib00f。
----------
7 确认操作系统的执行情况
怎么让它表现呢?如果还只是输出一条信息的话就太没意思了。考虑到将来我们肯定要做成Windows那样的画面,所以这次就来切换一下画面模式。我们这次做成的文件,就是projects/03_day下的harib00g。
; haribote-os
; TAB=4

        ORG        0xc200
        
        MOV        AL,0x13
        MOV        AH,0x00
        INT        0x10
fin:
        HLT
        JMP        fin

设定AH=0x00后,调用显卡BIOS的函数,这样就可以切换显示模式了。我们还可以在支持网页(AT)BIOS里看看
设置显卡模式(video mode)
AH=0x00;
AL=模式:(省略了一些不重要的画面模式)
0x03:16色字符模式,80× 25
0x12:VGA图形模式,640× 480× 4位彩色模式,独特的4面存储模式
0x13:VGA图形模式,320× 200× 8位彩色模式,调色板模式
0x6a:扩展VGA图形模式,800× 600× 4位彩色模式,独特的4面存储模式

参照以上说明,我们暂且选择0x13画面模式,因为8位彩色模式可以使用256种颜色,这一点看来不错。

如果画面模式切换正常,画面应该会变为一片漆黑。也就是说,因为可以看到画面的变化,所以能判断程序是否运行正常。由于变成了图形模式,因此光标会消失。

另外,这次还顺便修改了其他一些地方。首先将ipl.nas的文件名变成了ipl10.nas。这是为了提醒大家这个程序只能读入10个柱面。另外,想要把磁盘装载内容的结束地址告诉给haribote.sys,所以我们在“JMP 0xc200”之前,加入了一行命令,将CYLS的值写到内存地址0x0ff0中。这样启动区程序就算完成了。
------
8 32位模式前期准备
32位模式下可以使用的内存容量远远大于1MB
可是,如果用32位模式就不能调用BIOS功能了。这是因为BIOS是用16位机器语言写的。如果我们有什么事情想用BIOS来做,那就全部都放在开头先做,因为一旦进入32位模式就不能调用BIOS函数了
再回头说说要使用BIOS做的事情。画面模式的设定已经做完了,接下来还想从BIOS得到键盘状态。所谓键盘状态,是指NumLock是ON还是OFF等这些状态
所以,我们这次只修改了haribote.nas。修改后的程序就是projects/03_day下的harib00h。

; haribote-os
; TAB=4

; BOOT_INFO関係
CYLS    EQU        0x0ff0            ; 设置启动区
LEDS    EQU        0x0ff1
VMODE    EQU        0x0ff2            ; 色数
SCRNX    EQU        0x0ff4            ; 解像度のX
SCRNY    EQU        0x0ff6            ; 解像度のY
VRAM    EQU        0x0ff8            ; 图像缓冲区の開始番地
        
        ORG        0xc200
        
        MOV        AL,0x13            ; 320x200x8bit
        MOV        AH,0x00
        INT        0x10
        MOV        BYTE [VMODE],8    ; 画面模式
        MOV        WORD [SCRNX],320
        MOV        WORD [SCRNY],200
        MOV        DWORD [VRAM],0x000a0000
        
; 键盘LED状態

        MOV        AH,0x02
        INT        0x16             ; keyboard BIOS
        MOV        [LEDS],AL        
fin:
        HLT
        JMP        fin

[VRAM]里保存的是0xa0000。在电脑的世界里,VRAM指的是显卡内存(videoRAM),也就是用来显示画面的内存。这一块内存当然可以像一般的内存一样存储数据,但VRAM的功能不仅限于此,它的各个地址都对应着画面上的像素,可以利用这一机制在画面上绘制出五彩缤纷的图案。

其实VRAM分布在内存分布图上好几个不同的地方。这是因为,不同画面模式的像素数也不一样
不同画面模式可以使用的内存也不一样。所以我们就预先把要使用的VRAM地址保存在BOOT_INFO里以备后用
这次VRAM的值是0xa0000。这个值又是从哪儿得来的呢?还是来看看我们每次都参考的(AT)BIOS支持网页。在INT 0x10的说明的最后写着,这种画面模式下“VRAM是0xa0000~0xaffff的64KB”

另外,我们还把画面的像素数、颜色数,以及从BIOS取得的键盘信息都保存了起来。保存位置是在内存0x0ff0附近。从内存分布图上看,这一块并没被使用,所以应该没问题。
------
9 开始导入C语言
终于准备就绪,现在我们直接切换到32位模式,然后运行用C语言写的程序。这就是projects/03_day下的harib00i。
程序里添加和修改了很多内容。首先是haribote.sys,它的前半部分是用汇编语言编写的,而后半部分则是用C语言编写的。所以以前的文件名haribote.nas也随之改成了asmhead.nas。并且,为了调用C语言写的程序,添加了100行左右的汇编代码。


下面讲C语言部分。文件名是bootpack.c。为什么要起这样的名字呢?因为以后为了启动操作系统,还要写各种其他的处理,我们想要把这些处理打成一个包(pack),所以就起了这么一个名字。最重要的核心内容非常非常短,如下所示:
void HariMain(void)
{
    fin:
        goto fin;
}

那么,这个bootpack.c是怎样变成机器语言的呢?如果不能变成机器语言,就是说得再多也没有意义。这个步骤很长,让我们看一看。
首先,使用cc1.exe从bootpack.c生成bootpack.gas。
第二步,使用gas2nask.exe从bootpack.gas生成bootpack.nas。
第三步,使用nask.exe从bootpack.nas生成bootpack.obj。
第四步,使用obi2bim.exe从bootpack.obj生成bootpack.bim。
最后,使用bim2hrb.exe从bootpack.bim生成bootpack.hrb。
这样就做成了机器语言,再使用copy指令将asmhead.bin与bootpack.hrb单纯结合到起来,就成了haribote.sys。
cc1是C编译器,可以将C语言程序编译成汇编语言源程序。但这个C编译器是笔者从名为gcc的编译器改造而来,而gcc又是以gas汇编语言为基础,输出的是gas用的源程序。它不能翻译成nask。
所以我们需要把gas变换成nask能翻译的语法,这就是gas2nask。
一旦转换成nas文件,它可就是我们的掌中之物了,只要用nask翻译一下,就能变成机器语言了。实际上也正是那样,首先用nask制作obj文件。obj文件又称目标文件,源自英文的“object”,也就是目标的意思。程序是用C语言写的,而我们的目标是机器语言,所以这就是“目标文件”这一名称的由来。
可能会有人想,既然已经做成了机器语言,那只要把它写进映像文件里就万事大吉了。但很遗憾,这还不行,事实上这也正是使用C语言的不便之处。目标文件是一种特殊的机器语言文件,必须与其他文件链接(link)后才能变成真正可以执行的机器语言。链接是什么意思呢?实际上C语言的作者已经认识到,C语言有它的局限性,不可能只用C语言来编写所有的程序,所以其中有一部分必须用汇编来写,然后链接到C语言写的程序上。

所以,为了将目标文件与别的目标文件相链接,除了机器语言之外,其中还有一部分是用来交换信息的。单个的目标文件还不是独立的机器语言,其中还有一部分是没完成的。为了能做成完整的机器语言文件,必须将必要的目标文件全部链接上。完成这项工作的,就是obj2bim。bim是笔者设计的一种文件格式,意思是“binary image”,它是一个二进制映像文件。

所以,实际上bim文件也“不是本来的状态,而是一种代替的形式”,也还不是完成品。这只是将各个部分全部都链接在一起,做成了一个完整的机器语言文件,而为了能实际使用,我们还需要针对每一个不同操作系统的要求进行必要的加工,比如说加上识别用的文件头,或者压缩等。这次因为要做成适合“纸娃娃操作系统”要求的形式,所以笔者为此专门写了一个程序bim2hrb.exe
--------------
10 实现HLT(harib00j)
首先写了下面这个程序,naskfunc.nas。
; naskfunc
; TAB=4

[FORMAT "WCOFF"]                ; 制作目标文件的模式    
[BITS 32]                        ; 32位模式用的机器语言


; 制作目标文件的信息

[FILE "naskfunc.nas"]            ; 源文件名

        GLOBAL    _io_hlt            ; 程序中包含的函数名


; 以下是实际函数

[SECTION .text]        ; 目标文件中写了这些后再写程序

_io_hlt:    ; void io_hlt(void);
        HLT
        RET
也就是说,是用汇编语言写了一个函数。函数名叫io_hlt。虽然只叫hlt也行,但在CPU的指令之中,HLT指令也属于I/O指令,所以就起了这么一个名字。顺便说一句,MOV属于转送指令,ADD属于演算指令。

用汇编写的函数,之后还要与bootpack.obj链接,所以也需要编译成目标文件。因此将输出格式设定为WCOFF模式。另外,还要设定成32位机器语言模式。

在nask目标文件的模式下,必须设定文件名信息,然后再写明下面程序的函数名。注意要在函数名的前面加上“_”,否则就不能很好地与C语言函数链接。需要链接的函数名,都要用GLOBAL指令声明。

下面写一个实际的函数。写起来很简单,先写一个与用GLOBAL声明的函数名相同的标号(label),从此处开始写代码就可以了。这次新出现的RET指令,相当于C语言的return,意思就是“函数的处理到此结束,返回吧”,简洁明了。在C语言里使用这个函数的方法非常简单。我们来看看bootpack.c。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
如果您想在C语言导入图片,可以使用C语言自带的文件操作函数库,例如`fopen`、`fread`等函数来读取图片数据,并使用C语言的数组或结构体来存储图片的像素信息。具体步骤如下: 1. 打开图片文件,使用`fopen`函数打开图片文件,获取文件的指针。 ```c FILE* fp = fopen("image.bmp", "rb"); if (fp == NULL) { printf("Failed to open image file!\n"); return -1; } ``` 这里以BMP格式的图片为例,使用二进制读取模式打开文件。 2. 读取图片头信息,使用`fread`函数读取图片头信息,获取图片的宽度、高度、位深等信息。 ```c unsigned char bmp_header[54]; fread(bmp_header, sizeof(unsigned char), 54, fp); int width = *(int*)&bmp_header[18]; int height = *(int*)&bmp_header[22]; int bit_count = *(short*)&bmp_header[28]; ``` 这里假设BMP格式的图片头信息为54个字节,读取图像宽度、高度和位深的偏移量分别为18、22和28字节,使用指针强制类型转换的方式获取这些信息。 3. 读取像素数据,使用`fread`函数读取像素数据,并将像素数据存储在C语言的数组或结构体中。 ```c unsigned char* pixels = (unsigned char*)malloc(width * height * 3); // 每个像素占用3个字节 fread(pixels, sizeof(unsigned char), width * height * 3, fp); ``` 这里假设每个像素占用3个字节,使用`malloc`函数动态分配存储像素数据的内存空间,然后使用`fread`函数读取像素数据。 4. 关闭文件,使用`fclose`函数关闭文件。 ```c fclose(fp); ``` 完整的代码如下: ```c #include <stdio.h> #include <stdlib.h> int main() { FILE* fp = fopen("image.bmp", "rb"); if (fp == NULL) { printf("Failed to open image file!\n"); return -1; } unsigned char bmp_header[54]; fread(bmp_header, sizeof(unsigned char), 54, fp); int width = *(int*)&bmp_header[18]; int height = *(int*)&bmp_header[22]; int bit_count = *(short*)&bmp_header[28]; unsigned char* pixels = (unsigned char*)malloc(width * height * 3); fread(pixels, sizeof(unsigned char), width * height * 3, fp); fclose(fp); // 在这里可以使用像素数据做一些处理或显示 free(pixels); return 0; } ``` 需要注意的是,这个方法只能读取简单的图片格式,例如BMP、PNG等,对于复杂的图片格式例如JPEG、GIF等,需要使用专业的图片库或者第三方库来处理。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值