逆向工程实战,在反汇编工具中理解汇编与伪C

devtools/2024/12/27 11:01:24/

前言

今天对某外国的机器中的某个音频解码库进行反汇编,发现了一些有趣的知识,故而记录下来,以防以后重复遇到。

现笔者使用的反汇编工具为“Ghidra”。可在GitHub上下载:NationalSecurityAgency/ghidra: Ghidra is a software reverse engineering (SRE) framework

Ghidra是由美国国家安全局研究局创建和维护的软件逆向工程 (SRE) 框架 。该框架包括一套功能齐全的高端软件分析工具,使用户能够在包括 WindowsmacOSLinux 在内的各种平台上分析编译代码。功能包括反汇编汇编、反编译、绘图和脚本,以及数百个其他功能。

Ghidrajava编写,因此使用Ghidra前先配置java运行环境。

注:本文不介绍Ghidra的使用

adpcm_decoder1函数

C代码

笔者通过获取机器日志,发现了机器存在音频解码函数,通过grep命令查询到了,存有相应解码函数的库文件,并将库文件pull了出来,我们来看一下该库文件中相应的解码函数:

void adpcm_decoder1(char *param_1,undefined2 *param_2,int param_3,int *param_4){bool bVar1;uint uVar2;int iVar3;uint uVar4;int iVar5;int iVar6;int iVar7;uint uVar8;char *local_2c;int local_28;undefined2 *local_24;iVar3 = __divsi3(param_3,0x14);for (iVar6 = 0; iVar6 < iVar3; iVar6 = iVar6 + 1) {Decryption(0x32,param_1 + iVar6 * 0x14);}bVar1 = false;uVar8 = 0;iVar5 = param_4[1];iVar6 = *param_4;iVar3 = *(int *)(&DAT_00019c5c + iVar5 * 4);local_2c = param_1;local_24 = param_2;for (local_28 = param_3 << 1; 0 < local_28; local_28 = local_28 + -1) {uVar2 = uVar8;if (!bVar1) {uVar8 = (uint)*local_2c;local_2c = local_2c + 1;uVar2 = (int)uVar8 >> 4;}uVar4 = uVar2 & 0xf;bVar1 = (bool)(bVar1 ^ 1);iVar5 = iVar5 + (&DAT_00019dc0)[uVar4];if (iVar5 < 0) {iVar5 = 0;}else if (0x58 < iVar5) {iVar5 = 0x58;}iVar7 = iVar3 >> 3;if ((int)(uVar4 << 0x1d) < 0) {iVar7 = iVar7 + iVar3;}if ((int)(uVar4 << 0x1e) < 0) {iVar7 = iVar7 + (iVar3 >> 1);}if ((uVar2 & 1) != 0) {iVar7 = iVar7 + (iVar3 >> 2);}if ((uVar2 & 8) != 0) {iVar7 = -iVar7;}iVar6 = iVar6 + iVar7;if (iVar6 < -0x8000) {iVar6 = -0x8000;}if (0x7fff < iVar6) {iVar6 = 0x7fff;}iVar3 = *(int *)(&DAT_00019c5c + iVar5 * 4);*local_24 = (short)iVar6;local_24 = local_24 + 1;}*param_4 = iVar6;param_4[1] = iVar5;return;
}

以上是反汇编后的结果,Ghidra将机器语言解析成汇编语言,再自动转换成相应的C语言。

Decryption函数并非ADPCM的一部分

可以看到,这是典型的ADPCM音频解码算法,笔者已经对比过经典的开源ADPCM音频解码算法的C语言版本,确实是相差无几,但在这段代码中,多出了以下内容:

  iVar3 = __divsi3(param_3,0x14);for (iVar6 = 0; iVar6 < iVar3; iVar6 = iVar6 + 1) {Decryption(0x32,param_1 + iVar6 * 0x14);}

在解释这一段内容前,读者需要先了解:该机器通过蓝牙接收语音数据,每语音帧20字节。但机器处理不一定是20字节一处理,根据音频收发的速率,丢包率以及buffer的大小决定。

现在笔者来解释一下这一段的内容:

  • param_3除以20,将结果赋值给iVar3变量,这里计算出一共进来多少语音帧需要处理,每帧20字节
  • 一个for循环,将每帧数据丢入Decryption函数中进行处理
  • Decryption函数接收两个参数,0x32,一个固定的常量值;param_1 + iVar6 * 0x14,param_1是语音数据的数组指针

Decryption函数的参数问题

这里解释一下Decryption函数接收的两个参数,实际上,Decryption函数接收三个参数,在这里,Ghidra在C中只给出了两个参数,笔者不清楚这是Ghidra的特性还是工具缺陷。对汇编不感兴趣的同学可以跳过这一部分,但推荐看看

下面会给出Decryption的函数体,在函数体中可以发现Decryption是接收三个参数的,这里我们讲解一下这一段for循环的汇编内容

C语言:

for (iVar6 = 0; iVar6 < iVar3; iVar6 = iVar6 + 1) {Decryption(0x32,param_1 + iVar6 * 0x14);
}

汇编

                          LAB_00016624      00016624 bc  42           cmp        r4,r700016626 09  da           bge        LAB_0001663c00016628 14  22           movs       r2,#0x140001662a 62  43           muls       r2,r40001662c 01  9b           ldr        r3,[sp,#local_2c ]0001662e 32  20           movs       r0,#0x3200016630 9a  18           adds       r2,r3,r200016632 11  1c           adds       r1,r2,#0x000016634 ff  f7  38  ff   bl         Decryption     00016638 01  34           adds       r4,#0x10001663a f3  e7           b          LAB_00016624

这是这个for循环中涉及到的汇编语言,我们尝试在这段汇编中找到Decryption的三个参数

  • cmp 指令表示比较,即比较r4r7寄存器中的值,结合c代码,可以看出,r4r7分别代表了iVar6iVar3,但目前不清楚哪个对哪个。
  • bge 指令是分支指令,表示条件跳转(branch if greater or equal),即如果大于等于,则跳转。这两个指令合起来为,如果r4≥r7,则跳转到LAB_0001663c代码段,可以看出,已经超出了这个for循环的地址,即退出了for循环。
  • movs 指令表示移动,将立即数0x14移动到r2寄存器中,现在r2寄存器中存储了0x14这个值。
  • muls 指令表示乘法,即将r2r4寄存器中的值相乘,结合c代码可以看到,代码中仅有一个乘法,即iVar6 * 0x14,可以得出,r2 = 0x14,r4 = iVar6
  • ldr 指令表示加载寄存器(load register),语法为:ldr <寄存器>, [<基址寄存器>, <偏移量>],从基址寄存器中取出地址,加上偏移量,将结果加载到寄存器中,这里取出的地址加载到r3中,在c代码中,只有一个涉及到取址操作,即param_1,这是一个adpcm_decoder1函数接受的传参,传入的是一个指针,即param_1其实是一个数组指针。这里是获取param_1数组的地址,放到r3中。
  • movs 指令表示移动,将立即数0x32移动到r0寄存器中,现在r0寄存器中存放了0x32这个值。
  • adds 指令表示加法,将r3r2相加,并存放到r2寄存器中,即,param_1的地址加上一个计算的偏移量,本质上是从param_1的语音数据中获取第n个语音帧的地址。
  • adds 指令表示加法,将r2寄存器的值加上立即数0x00存放到r1寄存器中,这操作让r2寄存器存放的内容与r1一致。
  • bl 指令表示跳转,即跳转到Decryption函数的位置去,执行Decryption函数,并把下一条指令的地址存放到lr寄存器中,以方便Decryption函数执行完能回来,相当于压栈,将Decryption函数压入函数栈。
  • adds 指令表示加法,将r4 加上立即数0x01,并存放到r4中,即for循环的++操作。

可以看到,在调用Decryption函数前,分别给三个寄存器存放了东西,分别为r0存放了0x32r1存放了param_1的偏移地址,r2存放与r1相同的东西。这三个寄存器就是Decryption函数用的三个参数。如果还不确定,可以跳转到Decryption函数中查看相应的param_1,param_2,param_3三个参数调用时所使用的寄存器,分别为r0r1r2,本文给出结论,不做赘述。

小结

总而言之,这里的音频解码,虽然使用的是ADPCM算法,但是在raw dataadpcm data之间还加入了一个加密解密过程,即,实际上的过程为:

原始音频数据raw data → adpcm压缩→ 加密算法 encryption → 蓝牙传输 → 解密算法 decryption → adpcm解压 → 原始音频数据 raw data

因此,接下来看如何用解密算法还原加密算法

decryption函数

C代码

下面是decryption函数

void Decryption(byte param_1,int param_2,int param_3){int *piVar1;byte bVar2;int iVar3;int iVar4;byte local_30 [20];int local_1c;local_1c = __stack_chk_guard;iVar3 = 0;do {local_30[iVar3] = *(byte *)(param_2 + iVar3);iVar3 = iVar3 + 1;} while (iVar3 != 0x14);iVar3 = 0x13;do {iVar4 = iVar3 * 4;piVar1 = &DAT_00019c08 + iVar3;bVar2 = (byte)iVar3;iVar3 = iVar3 + -1;local_30[*(int *)(&DAT_00019c0c + iVar4)] =local_30[*(int *)(&DAT_00019c0c + iVar4)] + local_30[*piVar1] ^ bVar2 ^ param_1;} while (iVar3 != 0);local_30[0] = local_30[0] - 0x3e ^ param_1;iVar3 = 0;do {*(byte *)(param_3 + iVar3) = local_30[iVar3];iVar3 = iVar3 + 1;} while (iVar3 != 0x14);if (local_1c != __stack_chk_guard) {/* WARNING: Subroutine does not return */__stack_chk_fail();}return;
}

笔者依次解释上面的代码

数据本地化

  iVar3 = 0;do {local_30[iVar3] = *(byte *)(param_2 + iVar3);iVar3 = iVar3 + 1;} while (iVar3 != 0x14);iVar3 = 0x13;

这一段代码其实很好看出是在做什么,就是将传入的数据存在函数内部的局部变量中,iVar3变量是一个很重要的变量,在后续计算中会用到,记住它。在将所有数据搬完之后(一共20个字节,一语音帧20个字节),iVar3变量来到了0x13,十进制19

第19到1的数据解码

  do {iVar4 = iVar3 * 4;piVar1 = &DAT_00019c08 + iVar3;bVar2 = (byte)iVar3;iVar3 = iVar3 + -1;local_30[*(int *)(&DAT_00019c0c + iVar4)] =local_30[*(int *)(&DAT_00019c0c + iVar4)] + local_30[*piVar1] ^ bVar2 ^ param_1;} while (iVar3 != 0);

这里有一个很容易混淆的地方,就是标题采用的“第19到第1”这个概念,我们只能说,从while循环,从iVar3变量的角度,是“从19到1”,而从数据的角度说并不是这样。为了强化读者这个概念,我们先介绍local_30[*(int *)(&DAT_00019c0c + iVar4)]local_30[*piVar1]这两个变量。

local_30[*(int *)(&DAT_00019c0c + iVar4)]

首先,从定义上看byte local_30 [20];,local_30是一个数组的局部变量,且具有20个元素的大小,每个元素的大小为1byte,即,该数组是专门存放音频数据的。

其次,我们先看local_30[*(int *)(&DAT_00019c0c + iVar4)],数组的内部index部分很复杂,我们来做介绍

  • &DAT_00019c0c是内存中的一个地址
  • &DAT_00019c0c + iVar4表示这个地址 + 一个偏移量,组成一个新的地址
  • (int *)(&DAT_00019c0c + iVar4)表示将这个地址被强制类型转换为int类型的指针
  • *(int *)(&DAT_00019c0c + iVar4)表示取值这个int指针,即从这个地址中取值,这个值是int类型的

然后,我们来看这个iVar4变量,iVar4 = iVar3 * 4,在每一次循环中,iVar4都会被更新一次,且更新为iVar3 * 4iVar3在每次循环中会被做-1操作。也就是说,第一次iVar4 = 19 * 4,第二次为iVar4 = 18 * 4,以此类推。

最后,我们查查这个DAT_00019c0c,以及它对应的偏移量中究竟存了什么东西,通过Ghidra汇编地址中可以查到:

                             DAT_00019c0c 00019c0c 00              ??         00h00019c0d 00              ??         00h00019c0e 00              ??         00h00019c0f 00              ??         00h00019c10 0c              ??         0Ch00019c11 00              ??         00h00019c12 00              ??         00h00019c13 00              ??         00h00019c14 0d              ??         0Dh# 为方便阅读,此处省略部分......00019c4c 01              ??         01h00019c4d 00              ??         00h00019c4e 00              ??         00h00019c4f 00              ??         00hDAT_00019c50                               00019c50 02  00  00  00    undefine   00000002hDAT_00019c54                                     00019c54 07  00  00  00    undefine   00000007hDAT_00019c58                                     00019c58 08  00  00  00    undefine   00000008h

0x00019c0c地址存放的数据为0x00,隔4个字节后,0x00019c10地址存放的数据为0x0c…一直到19 * 4个字节,即0x00019c58存放的数据为0x08,通过查询并列出发现,其数据依次为:

0x0 0xc 0xd 0x3 0x4
0x9 0xa 0xb 0x10 0x11
0x12 0x13 0x5 0x6 0xe
0xf 0x1 0x2 0x7 0x8

刚好是0~19,即根据通过’iVar3’的依次从19到1的变化,local_30数组依次参与解码的数据为8,7,2,1,…,4,3,13,12,0,而非按顺序解码。

local_30[*piVar1]

在这里我们可以看到,该变量依然是local_30数组中的元素,只不过其index表示为*piVar1

piVar1 = &DAT_00019c08 + iVar3; 一些敏锐的同学可能注意到,在我们介绍的前一个变量中,地址都是四个字节一偏移,在内存地址中查询数据的分布,也是四个字节存一个数据,其它地方都是0,而按照我们的理解,piVar1 = &DAT_00019c08 + iVar3 * 4;似乎才是正确的。我们查看一下相应的汇编代码:

        000164da ee  59           ldr        r6,[r5,r7]=>DAT_00019c58                       000164dc 04  3d           subs       r5,#0x4000164de 7d  59           ldr        r5,[r7,r5]=>DAT_00019c54                    

这一段代码,分别是做了*(int *)(&DAT_00019c0c + iVar4)的计算以及piVar1 = &DAT_00019c08 + iVar3的计算,我们来解释一下:

  • ldr指令表示加载寄存器,先计算r5r7寄存器相加的值,再传给r6寄存器,ghidra已经给出了第一次循环中指向的地址,即0x00019c58
  • subs指令表示减法,将r5寄存器中的值减去立即数0x04并传给寄存器r5
  • 与第一条指令相同,且ghidra给出了第一次循环中指向的地址,即0x00019c54

这样可以看出,实际上,寄存器r5r6都是读地址值且相差仅有4个字节。也就是说,piVar1&DAT_00019c08 + iVar3 * 4仅仅相差了4个字节,例如:在第一次循环中,&DAT_00019c08 + iVar3 * 4中的值为0x8,piVar1中的值为0x7,在最后一次循环中,&DAT_00019c08 + iVar3 * 4的值为0xc,piVar1的值为0x0。

为什么C代码会与汇编相差如此之大呢,第一,在GCC编译C语言的过程中,本身会对代码进行优化,即调整指令顺序,甚至优化掉部分操作,以提升效率;第二,从已经编译过的二进制文件反推出C语言代码本身就是非常难的一件事情,正如数学中,求导的难度往往比求原函数的难度要低。所以有些细微的差池是可能的,因此在逆向工程中,除了要看C代码,还要检查汇编代码以及内存的走向。

解码过程

现在我们了解了那两个复杂的变量,现在我们来讲一讲解码的算法

    local_30[*(int *)(&DAT_00019c0c + iVar4)] =local_30[*(int *)(&DAT_00019c0c + iVar4)] + local_30[*piVar1] ^ bVar2 ^ param_1;

在这条运算中,我们可以了解到local_30[*(int *)(&DAT_00019c0c + iVar4)]表示当前解码运算的数据,local_30[*piVar1]表示下一个解码运算的数据,例如:当*(int *)(&DAT_00019c0c + iVar4)=8时,*piVar1 = 7,这样上式就变成了:

    local_30[8] = local_30[8] + local_30[7] ^ bVar2 ^ param_1;

依次类推,到最后一次循环时,上式变成:

    local_30[12] = local_30[12] + local_30[0] ^ bVar2 ^ param_1;

我们假设,存在一个table[20]

table[20] = {0x0,0xc,0xd,0x3,0x4,\0x9,0xa,0xb,0x10,0x11,\0x12,0x13,0x5,0x6,0xe\0xf,0x1,0x2,0x7,0x8
}

那么整个while循环可以改写成:

  do {local_30[table[iVar3]] =local_30[table[iVar3]] + local_30[table[iVar3-1]] ^ bVar2 ^ param_1;iVar3 = iVar3 + -1;} while (iVar3 != 0);

这样,读者应该可以理解,我们从iVar3的角度上来讲,是“从第19到1的解码”,但从数据实际的解码顺序来讲,其实是“8,7,2,1,15,14,6,5,19,18,17,16,11,10,9,4,3,13,12”的顺序解码,0在后面单独解码。

这19个数据,每一个解码,都依赖于前一个还未解码的数据,即当解码第8个数据的时候,需要依赖第7个数据,当解码第12个数据时,需要依赖第0个数据。

现在我们仔细讲一下式子,在该式子中, bVar2 = (byte)iVar3;即,bVar2iVar3相同,从19到1根据循环次数递减,param_1是一个常量,为0x32,是从adpcm_decoder1中传进来的第一个参数,如果不记得可往上翻阅。

在这条式子中,既有加法,又有异或运算,那么问题来了,该怎样计算呢?或者说,先算哪个呢?

这时熟悉C语言的同学们会说,在C语言中,加减法的优先级高于异或运算的优先级,因此先计算local_30[table[iVar3]] + local_30[table[iVar3-1]],再计算 ^ bVar2 ^ param_1,答案是没错的,在不同的语言中,异或运算与加减法的优先级可能会有所不同,因此这里的运算优先级要额外注意,但同理,在逆向工程中,不要过分相信逆向工具给你的C代码,它不一定是你想象的那样,因此我们还是来看一下它的汇编代码

                             LAB_000164d6000164d6 1a  1c           adds       r2,r3,#0x0000164d8 9d  00           lsls       r5,r3,#0x2000164da ee  59           ldr        r6,[r5,r7]=>DAT_00019c58000164dc 04  3d           subs       r5,#0x4000164de 7d  59           ldr        r5,[r7,r5]=>DAT_00019c54000164e0 42  40           eors       r2,r0000164e2 01  92           str        r2,[sp,#local_3c ]000164e4 4a  5d           ldrb       r2,[r1,r5]000164e6 8d  5d           ldrb       r5,[r1,r6]000164e8 01  3b           subs       r3,#0x1000164ea 94  46           mov        r12 ,r2000164ec 01  9a           ldr        r2,[sp,#local_3c ]000164ee 65  44           add        r5,r12000164f0 55  40           eors       r5,r2000164f2 8d  55           strb       r5,[r1,r6]000164f4 00  2b           cmp        r3,#0x0

因为式子中有一个“+”和两个“^”,因此我们只要着重看“add”指令与“eor”指令即可。

  • adds r2,r3,#0x0:这条指令并不是加法指令,而是将r3寄存器中的值赋值给r2寄存器,这个寄存器中实际存了什么值呢,我们看后续有一个subs r3,#0x1,因此可知,r3寄存器是在循环中递减的,这符合bVar2的特征,因此可以断定,r2寄存器存储的实际上就是bVar2
  • eors r2,r0r2寄存器与r0寄存器的值做异或运算,将结果存放到r2中,在我们之前提到,r0寄存器存储的是param_1传参,即0x32固定参数,因此这条指令实际上就是bVar2 ^ param_1
  • ldrb r2,[r1,r5]:指令表示加载寄存器,r5r6两个寄存器的内容我们上面分析过,[r1,r5]表示目标内存地址,这个地址是通过将寄存器r1r5的值相加得来的,r1param_2,即数据数组指针,r5为上面分析时讲到的乱序查表得到的偏移量。两者相加则索引到对应的数据。将其存放在r2中,下面ldrb r5,[r1,r6]一样。
  • mov r12 ,r2:该指令将r2寄存器中的值转移到r12寄存器,因此,现在r12r2的内容一样。
  • add r5,r12:该指令将r12r5相加,即式子中的两个local_30相加,并将结果存放到r5中。
  • eors r5,r2:将r5寄存器与r2寄存器中的值做异或运算。

通过汇编指令,我们可以推算出运算的优先级为:

r 1 = b V a r 2 ⊕ p a r a m 1 r 2 = l o c a l _ 30 [ ∗ ( i n t ∗ ) ( & D A T _ 00019 c 0 c + i V a r 4 ) ] + l o c a l _ 30 [ ∗ p i V a r 1 ] r e s = r 1 ⊕ r 2 r1 = bVar2 \oplus param_1\\ r2 = local\_30[*(int *)(\&DAT\_00019c0c + iVar4)] + local\_30[*piVar1]\\ res = r1 \oplus r2 r1=bVar2param1r2=local_30[(int)(&DAT_00019c0c+iVar4)]+local_30[piVar1]res=r1r2

第0个数据的解码

  local_30[0] = local_30[0] - 0x3e ^ param_1;iVar3 = 0;

第0个数据不依赖于任何其它的数据,仅靠自身便可完成解码

其对应的汇编代码也很简单,如果读者理解了上面的过程,那么理解这一段汇编代码也是轻而易举,在此笔者不做过多赘述

        000164fc 3e  38           subs       r0,#0x3e000164fe 50  40           eors       r0,r200016500 08  70           strb       r0,[r1,#0x0 ]=>local_30

返回数据

  do {*(byte *)(param_3 + iVar3) = local_30[iVar3];iVar3 = iVar3 + 1;} while (iVar3 != 0x14);

在此过程中,通过while循环将local_30中已经完成解码的数据全部放到param_3参数中,完成了数据的返回(因为param_3是一个指针,因此对指针数据做修改在函数退出时修改依然成立,而不会随着函数出栈而消失)。

总结

在这个反汇编的过程中,我们通过C代码与汇编代码的结合,还原出了解码过程的真实情况,因此,在反汇编中除了看反汇编工具提供的c语言代码,也需要看原来的汇编代码。所以,在逆向工程中,学会阅读汇编代码对你的帮助时巨大的,会避免因编译优化等问题带来的困扰,同时,笔者推荐读一读《深入理解计算机系统》,会让你对C和逆向有更深层次的理解,最后祝你变得更强!


http://www.ppmy.cn/devtools/145337.html

相关文章

隐马尔科夫模型|前向算法|Viterbi 算法

隐马尔可夫模型 (Hidden Markov Model, HMM) HMM 是一种统计模型&#xff0c;用于表示由一个隐藏的马尔可夫链生成的观测序列。它假设每个观测值依赖于当前的隐藏状态&#xff0c;并且隐藏状态之间的转换遵循马尔可夫性质&#xff08;即未来的状态仅依赖于当前状态&#xff0c…

迪文串口屏_T5L平台_界面状态图标显示和隐藏

迪文串口屏_T5L平台_界面状态图标显示和隐藏 参考迪文官方视频教程&#xff1a;http://inforum.dwin.com.cn:20080/forum.php?modviewthread&tid7240&fromuid42572 一、软件准备&#xff08;参考上一篇文章&#xff09; 二、打开项目&#xff0c;添加变量图标显示 三…

内容与资讯API优质清单

作为开发者&#xff0c;拥有一套API合集是必不可少的。这个开发者必备的API合集汇集了各种实用的API资源&#xff0c;为你的开发工作提供了强大的支持&#xff01;无论你是在构建网站、开发应用还是进行数据分析&#xff0c;这个合集都能满足你的需求。你可以通过这些免费API获…

嵌入式硬件杂谈(七)IGBT MOS管 三极管应用场景与区别

引言&#xff1a;在现代嵌入式硬件设计中&#xff0c;开关元件作为电路中的重要组成部分&#xff0c;起着至关重要的作用。三种主要的开关元件——IGBT&#xff08;绝缘栅双极型晶体管&#xff09;、MOSFET&#xff08;金属氧化物半导体场效应晶体管&#xff09;和三极管&#…

vue预览和下载 pdf、ppt、word、excel文档,文件类型为链接或者base64格式或者文件流,

** 方法1&#xff1a;word、xls、ppt、pdf 这些文件&#xff0c; 如果预览的文件是链接可以直接打开&#xff0c;可用微软官方的预览地址 ** <iframe width"100%" :src"textVisibleURl " id"myFramePPT" style"border: none;backgroun…

Hive其九,排名函数,练习和自定义函数

目录 一、排名函数 -大名鼎鼎 1、row_number() 2、rank() 2、rank() 3、dense_rank() 二、练习 三、自定义函数 1、将字母变大写案例 2、输入 出生年月日求年龄 函数 一、排名函数 -大名鼎鼎 row_number() rank() dense_rank() 1、row_number() row_number从1开始&a…

青少年编程与数学 02-004 Go语言Web编程 19课题、API文档

青少年编程与数学 02-004 Go语言Web编程 19课题、API文档 一、API文档二、生成工具三、使用Swagger步骤 1&#xff1a;安装必要的工具步骤 2&#xff1a;安装Swagger相关的Go库步骤 3&#xff1a;编写API代码并添加注释步骤 4&#xff1a;生成Swagger文档步骤 5&#xff1a;运行…

修改el-select下拉框高度;更新:支持动态修改

文章目录 效果动态修改&#xff1a;效果代码固定高度版本动态修改高度版本&#xff08;2024-12-25 更新&#xff1a; 支持动态修改下拉框高度&#xff09; 效果 动态修改&#xff1a;效果 代码 固定高度版本 注意点&#xff1a; popper-class 尽量独一无二&#xff0c;防止影…