Analice el proceso de llamada al método desde el nivel de CPU

Prólogo

Todos sabemos que en C, C ++, Java y otros lenguajes, el código se ejecuta en unidades de métodos. Por ejemplo, C necesita una int main()función como método inicial de ejecución, y Java también necesita un public static void main(String[] args)método de ejecución como código. Luego, con este método como principio, se llamarán más y más métodos en el seguimiento para lograr una variedad de cosas diferentes.

El contenido de esta serie es comprender cómo funciona la llamada a la función en la CPU en el nivel de la llamada a la CPU. Debido a que este contenido contendrá mucho contenido de ensamblaje, es mejor que necesite algunos conocimientos de ensamblaje. Por supuesto, también explicaré las instrucciones de montaje utilizadas.

Este artículo se basa en el lenguaje de máquina X86_64, es decir, cuando solemos descargar software, veremos que hay una versión X86_64 en la versión de Linux, y esta versión también es la versión que la mayoría de la gente elegirá descargar. También se puede decir que X86_64 es un tipo de conjunto de instrucciones que la CPU puede usar.

El contenido de este artículo también se explicará en función de un código C relativamente simple, porque el código complejo aumentará la dificultad de comprensión. El código es el siguiente:

#include <stdio.h>
int static add(int i, int j){
    int x = 3;
    int y = 4;
    int sum = x + y + i + j;
    return sum;
}

int main() {
    int x = 1;
    int y = 2;
    int z = add(x, y);
    return z;
}
复制代码

El código tiene dos funciones main()y add()es muy simple y básicamente tiene la experiencia de usar un lenguaje que se puede entender.

Cómo compilar el código

Hemos escrito el código fuente, pero el código no se puede ejecutar directamente porque la CPU no reconoce lo que hemos escrito. Necesitamos compilar y compilar el código en datos binarios reconocidos por la CPU antes de que la CPU pueda ejecutarlo basándose en estos datos.

Yo uso gcc para compilar en ubuntu.

Instalar herramientas de compilación

La herramienta de compilación es indudablemente gcc

Instala gcc y ejecuta el siguiente comando.

sudo apt-get install gcc

Generalmente, gcc se instalará de manera predeterminada, si no está seguro, puede ejecutar el siguiente comando para determinar si desea instalar

gcc --version

Si se imprime el número de versión, se instala.

Cómo compilar

Si necesitamos usar gcc para compilar, entonces debemos usar el comando gcc

gcc main.c -g -o main

Entre ellos main.cestá la ruta para almacenar el código fuente, que puede ser una ruta relativa o una ruta absoluta. Podemos generar un archivo ejecutable a través de este comando main.

El cuadro rojo es el archivo ejecutable, es decir, el archivo que generamos a través del comando, necesitamos ejecutarlo más tarde.

Cómo depurar código

Aunque hemos compilado el código, actualmente no tenemos herramientas para ejecutar / depurar este código compilado. Por lo tanto, también necesitamos instalar una herramienta de depuración, similar a la herramienta de depuración, y aquí usaré el gdb original para la depuración, hay muchas otras herramientas que también se pueden depurar, como la versión mejorada de nemiver con interfaz visual, gdb cgdb etc.

Herramientas de instalación y depuración.

Instalar y depurar herramientas también es muy simple, solo ejecute el siguiente comando

sudo apt-get install gdb

La herramienta también se instala de manera predeterminada, puede usar el siguiente comando para determinar si desea instalar

gdb --versionSi se imprime el número de versión, se instala.

Cómo depurar

Es problemático depurar el código. Primero enumeraré los comandos gdb necesarios aquí, y luego explicaré cada comando. Si encuentra comandos que no conoce durante el proceso de lectura, puede verificarlos aquí.

  • gdb main: Este comando significa depurar el archivo ejecutable principal.
  • start: Después de ejecutar la ejecución anterior, aún no podemos depurar, necesitamos ejecutar este comando para iniciar la depuración del código de ejecución. Este comando indica el inicio de la ejecución del código.
  • next: Este comando es similar a nuestra ejecución de un solo paso. Ingresar este comando una vez es ejecutar un código, que puede abreviarse comon
  • disassemble /rm: Este comando se usa para mostrar el código de desmontaje, podemos ver el código de ensamblaje del código C que escribimos a través de este comando. /mIndica que el código fuente y el código de ensamblaje están dispuestos juntos, /rlo que indica que se puede ver el código hexadecimal, que se puede abreviar comodisas
  • info register: Cambie el comando para ver el valor en el registro en este momento, que infopuede abreviarse como i, registerpuede abreviarse comor
  • step: También es una ejecución de un solo paso, pero el comando indica que cuando encontremos una función, ingresaremos a la función, si directamente, nextsaltaremos directamente la función que encontremos. Puede ser abreviado comos
  • list: El comando para ver el código fuente se puede abreviar comol
  • x /nfu: Este comando está más copiado, puede ver la unidad de memoria.
    • nIndica el número de celdas de memoria que se mostrarán, que puede ser un número decimal directo
    • fRepresenta el modo de visualización, puede tomar los siguientes valores
      • x: Mostrar variables en hexadecimal
      • d: Mostrar variables en decimal
      • u: Mostrar enteros sin signo en decimal
      • o: Mostrar variables en formato octal
      • t: Mostrar variables en formato binario
      • .......
    • uRepresenta la longitud de una unidad de dirección, puede tomar los siguientes valores
      • b: Indica un solo byte
      • h: Indica bytes dobles
      • w: Indica cuatro bytes
      • g: Indica ocho bytes
  • p var: Indica el valor de la variable que se va a ver, que vares el nombre de la variable que se va a ver; si desea ver la dirección de la variable, puede usarp &var
  • q: Este comando significa salir de la depuración

Método de análisis de proceso de llamada

Analizaré el código un poco a través de la depuración de gdb, principalmente para el análisis del código de ensamblaje.Al mismo tiempo, también explicaré el papel de las instrucciones de ensamblaje para que no necesite encontrar información usted mismo.

Primero, iniciamos la gdb maindepuración y luego startcomenzamos a depurar:

En este punto, podemos comenzar a depurar con los comandos mencionados anteriormente.

Variables locales

Primero, echemos un vistazo al código de ensamblaje en main. Explicaré el código de ensamblaje:

El cuadro azul corresponde al desplazamiento del código.

Primero vemos la primera línea de código, push %rbpque %rbprepresenta un registro, y hay muchos registros en la CPU:

La imagen es de la Sección 3.4 de "Comprensión detallada del sistema informático Tercera edición".

Como se puede ver en la figura, hay un total de 16 registros en la CPU, cada registro puede almacenar hasta 64 bits de datos, y cada registro tiene un nombre, donde usamos diferentes nombres para el mismo registro, para registrar Qué parte del byte se opera, por ejemplo :, %eaxluego operamos los 32 bits inferiores del primer registro; luego, operamos %axlos 16 bits inferiores del %rbxprimer registro; luego operamos los 64 bits del primer registro . La operación puede ser de escritura o lectura.

push¿Qué hace la instrucción? La instrucción tiene un operando. De hecho, empuja el valor del operando a la pila, y la pila es en realidad un área de memoria continua en la memoria, pero los datos de la pila comienzan desde la dirección alta Dirección baja empuja datos.

Entonces, ¿cómo sabe la CPU dónde está esta dirección de pila? Es a través de un %rspregistro, que almacena la dirección de la parte superior de la pila. Sabemos que %rsppuede almacenar datos de 64 bits, y nuestro sistema es de 64 bits, por lo que solo puede almacenar una dirección de memoria de 64 bits. Cuando deseamos almacenar datos en la pila, primero cambiaremos %rspla dirección del registro, ya que se extiende a una dirección más baja, por lo que debemos restar un valor y luego almacenar los datos en la memoria extendida.

Por ejemplo: queremos almacenar un 0x0123H en la pila, primero %rsp= %rsp-2, porque los datos son solo dos bytes, por lo que solo necesitamos mover dos unidades de memoria y luego colocar 0x0123H en las dos unidades de memoria extendidas. Una unidad de memoria es un byte.

La primera compilación push %rbp:

A través del conocimiento anterior, sabemos que la instrucción es empujar el %rbpvalor en el registro a la pila, y %rbpel valor se almacena en la dirección de la parte inferior de la pila en cada marco de pila. Un método corresponde a un marco de pila en la pila. Entonces, esta instrucción es poner la dirección inferior del marco de pila anterior (llamador) en la pila. Entonces, ¿el main()método tiene un llamador? Sí, el main()método es realmente __start()llamado por un método, y este método Se agrega automáticamente durante la compilación.

La segunda compilación mov %rsp,%rbp:

movLa instrucción contiene dos operandos, que son el operando de origen y el operando de destino mov src, desc, que es %rsppasar el valor en el registro %rbp. Analizar a un lenguaje de alto nivel es una operación de asignación %rbp = %rsp. Los operandos de origen y destino de los diferentes conjuntos de instrucciones son diferentes. Por ejemplo, los operandos en el conjunto de instrucciones 8086 son mov desc, src. Para obtener más información, consulte el manual del conjunto de instrucciones.

%rspEl registro que conocemos contiene la dirección de la parte superior de la pila, y aquí es para almacenar la dirección de la parte superior de la pila %rbp, por lo que %rbpel valor en el registro se inserta inicialmente en la pila, porque si no se empuja a la pila, el valor original será Después de sobrescribirse, %rbpel valor en el registro original se puede guardar en la pila, y luego el valor que mian()se puede restaurar de la pila después de ejecutar el método %rbp.

Usamos para i rver la ejecución de esta instrucción, el registro:

La izquierda es el nombre del registro, el medio es el valor almacenado en cada registro, el valor se muestra en hexadecimal, hay un valor a la derecha, no significa que el registro pueda almacenar dos valores de 128 bits, el valor a la derecha solo se muestra en decimal Valor. Puedes convertirlo tú mismo. Por lo general, solo miramos el valor medio.

Cuando se ejecuta esta instrucción, podemos ver %rbpque %rsplos valores son ambos 0x7fffffffde50, lo que también indica que la dirección en la parte superior de la pila es 0x7fffffffde50.

La tercera compilación sub $0x10, %rsp:

subLa instrucción es una instrucción de resta, correspondiente a la addinstrucción, y también tiene dos operandos, esta instrucción se traduce a un lenguaje de alto nivel %rsp = %rsp - 0x10. Aquí está %rspel valor del registro menos 16, porque solo dijo que el uso del espacio de la pila comienza desde la dirección alta y se expande a la dirección baja, y el %rspregistro almacena la dirección de la parte superior de la pila, por lo que si desea usar la pila, debe Haga que %rspla dirección señalada por el registro sea más pequeña que la original. Entonces, esta instrucción expande 16 bytes de memoria para su uso posterior.

De acuerdo con el valor de la instrucción anterior, podemos calcular que %rspel valor en este momento debería ser 0x7fffffffde40. i rVerifiquemos a través de las instrucciones:

Primero imprimimos el código de ensamblaje, podemos encontrar que el código se detiene en la línea <+8> en este momento, puede saberlo por la pequeña flecha a la izquierda. La flecha azul y la flecha verde apuntan al registro %rbpy respectivamente , %rspy son exactamente las mismas que calculamos.

La cuarta compilación:movl $0x1,-0xc(%rbp)

¿Es esta instrucción movmuy similar a la instrucción? En realidad es movuna extensión de la movlinstrucción . La instrucción significa transferir palabras dobles. En términos simples, operará en los 32 bits inferiores de un bloque de memoria dado, mientras aumenta los 32 superiores. El bit se establece en 0. Hay varias instrucciones extendidas:

Instrucción Descripción
movb Transferir byte
movw Transferir palabra
movl Transmita palabras dobles, solo este comando establecerá el bit alto en 0
movq Enviar cuatro personajes
movabsq Enviar cuatro caracteres absolutos (no sé lo que significa absoluto)

Mencionamos anteriormente que %rbpla dirección se almacena en el registro, pero aquí se utiliza un ()registro para envolver el registro. Este soporte tiene un papel especial. Su función es tomar el valor de la dirección en el registro. Ya no es tomar directamente el valor del registro, sino tomar el valor en el registro como la dirección, y luego tomar la unidad de memoria en la dirección del contenido. Cualquiera que haya aprendido el lenguaje C sabe que esto no es un puntero. Sí, de hecho puede entenderse con un puntero. Aquí %rbppuede ver que sí &addr, pero se (%rbp)considera que sí *addr.

Entonces, ¿qué hace el número delante de los corchetes? Lo reescribiré para saber lo que hace:

-0xc(%rbp) => (%rbp + -0xc)

Es decir, la dirección en el registro se resta primero para obtener una nueva dirección, y luego se toma la unidad de memoria en la dirección. De acuerdo con el diagrama en la tercera instrucción, podemos saber que la %rbpdirección almacenada es 0x7fffffffde50, luego la nueva dirección obtenida es 0x7fffffffde44. Entonces, toda la instrucción es almacenar un 0x00000001 en las 4 unidades de memoria a partir de la nueva dirección.

Podemos verificar 0x7fffffffde44los datos en la unidad de memoria para verificar si este es el caso:

Imprimí el contenido de la dirección arriba y abajo de la dirección juntos para facilitar la comparación.

Preste atención al cuadro rojo. Esta es la unidad de memoria que almacenamos. Una cosa a la que debemos prestar especial atención aquí es que %rbpla dirección almacenada en el registro es la dirección de la pila, y la forma en que la pila usa la dirección es de mayor a menor, por lo que Rellene los datos con los bits más altos primero, y luego rellene los datos con los bits más bajos. Por lo tanto, los 0x00000001datos de orden superior se almacenarán en la dirección superior a su vez, y los datos de orden inferior se almacenarán en la dirección inferior.

Entonces, esta instrucción también corresponde a `int x = 1; esta declaración de asignación en código C.

La quinta compilación:movl $0x2,-0x8(%rbp)

Esta instrucción tiene el mismo efecto que la cuarta instrucción, calculamos que la nueva dirección es0x7fffffffde48

Podemos ver que el valor en esta dirección es 0x00000002.

Podemos sacar una conclusión a través de las instrucciones cuarta y quinta: las variables locales en el método se almacenan en la pila . Porque las declaraciones correspondientes a estas dos instrucciones son int x = 1;, int y = 2;y las variables x e y son todas variables locales.

Las siguientes instrucciones de la sexta a la novena son todas operaciones de asignación simples, que no se analizarán en detalle aquí, y puede deducir en función de lo que explicó anteriormente.

Paso de parámetros

Lo que quiero explicar aquí es que estas cuatro instrucciones se usan para pasar parámetros .

Como la décima instrucción es una callq 0x5555555545fa <add>, la función principal de esta instrucción es llamar a una función. El papel de esta instrucción se explicará en detalle más adelante.

A través del código C podemos saber que llamamos a main()la add(int i, int j)función en la función, que toma dos parámetros. Entonces, al llamar a una add()función, ¿cómo se pasan los parámetros a la add()función?

Primero observamos las dos primeras instrucciones, observamos sus operandos de origen y descubrimos que aquí es donde se almacenan las variables locales, y nuestro código sí transfiere las variables locales a la add()función, así que aquí las variables locales x, y El valor se transfiere al %edxregistro, %eaxregistro.

Entonces observado después de dos instrucciones, estarán simplemente %edx, %eaxel valor se transfiere al %esi, %edi. ¿Por qué usar otro registro para almacenar el valor completo? De hecho, a causa de una concentración predeterminada en la instrucción, si el parámetro es menor que siete, los parámetros de transferencia en el registro en el orden rdi, rsi, rdx, rcx, r8, r9. Debido a que hay sólo dos parámetros, los dos parámetros sólo a través del registro rdi, rsipara la entrega.

Entonces, ¿qué pasa si el número de parámetros supera los 6? Si hay más de 6 parámetros, el exceso se pasará a través de la pila. Nota: Solo el exceso será empujado a la pila. Es decir, los parámetros de la parte que se exceden serán empujados a la pila.

Llamada a la función

Después de que los parámetros se hayan almacenado en el registro o la pila, puede realizar una llamada a la función.

Vemos que las instrucciones de montaje que siguen son:callq 0x5555555545fa <add>

Encontramos una nueva instrucción callqque acepta un operando, que es una dirección de memoria.

En primer lugar, ¿necesitamos saber cómo la CPU ejecuta las instrucciones una por una? Además de los 16 registros enumerados en la tabla anterior, la CPU también tiene algunos registros para fines especiales. Uno de ellos es un ripregistro (comúnmente conocido como registro de PC). El propósito de este registro es almacenar la dirección de la siguiente instrucción.

¿Cómo almacena este registro la dirección de la siguiente instrucción? Primero veamos un código de ensamblaje más detallado: disas /rEsta instrucción imprimirá el número de bytes ocupados por cada instrucción.

Cuando la CPU adquiere una instrucción, no la ejecutará inmediatamente, sino que primero aumentará la dirección en el registro actual de la PC por el número de bytes de la instrucción actualmente adquirida, 下一条指令的地址 = 当前pc寄存器的地址 + 当前获取到的指令的字节数y solo después de cambiar la dirección en el registro de la PC Comience a ejecutar las instrucciones obtenidas.

Por ejemplo:

Suponiendo que la dirección actualmente almacenada en el registro de la PC es 0x0000555555554642, la CPU primero va a esta dirección para buscar la instrucción que se ejecutará, mov -0x8(%rbp),%edxy la longitud de la instrucción es de 3 bytes de la imagen. Después de que la CPU obtenga la instrucción, primero cambiará la dirección almacenada en el registro de la PC 0x0000555555554642 + 3 = 0x0000555555554645, por lo que el registro de la PC ahora almacena la dirección de la siguiente instrucción que se ejecutará 0x0000555555554645. Entonces la CPU comienza a ejecutar la instrucción obtenida.

Volviendo a nuestras callqinstrucciones, de acuerdo con el conocimiento anterior, sabemos que callq 0x5555555545fa <add>después de que la CPU obtiene la instrucción, primero modificará el valor del registro de la PC 0x000055555555464cy luego la CPU ejecutará la instrucción.

¿Qué hace la CPU al ejecutar esta instrucción? Podemos desglosar esta instrucción:

  1. sub 0x8, %rsp
  2. push %rip
  3. mov 0x00005555555545fa,%rip

Cuando la CPU ejecuta la callinstrucción, primero moverá el puntero en la parte superior de la pila a la dirección inferior en 8 bytes, lo que equivale a extender el contenido de 8 bytes, y luego empujará ripel valor en el registro actual a la pila, es decir, guardar La unidad de memoria de 8 bytes acaba de expandirse y, finalmente, %ripel valor del registro se cambia al valor del operando.

Y 0x00005555555545faes add()la dirección de la primera instrucción de la función, podemos imprimir el ensamblaje de la función y verificar:

De acuerdo con el cuadro rojo podemos conocer add()el código de ensamblaje, vemos que la dirección de la primera instrucción resulta ser callel operando de la instrucción.

Pensemos en otra pregunta ahora, ¿por qué deberíamos poner ripla dirección en la pila primero ? Todos sabemos que después de que se completa la ejecución de la función llamada, necesitamos volver a la función de llamada para continuar la ejecución. Por lo tanto, podemos poner la dirección de la próxima instrucción que se ejecutará de la función de llamada en la pila. Después de ejecutar la función llamada, coloque la dirección de la instrucción en la pila rippara continuar ejecutando las instrucciones posteriores de la función de llamada.

Aquí hay un suplemento para pushel análisis de la instrucción: si se ejecuta push %rbp, la instrucción se puede dividir en las siguientes instrucciones para comprender:

  1. sub 0x8,%rsp
  2. mov %rbp,0x8(%rsp)

En primer lugar, el contenido de las expande pila, el tamaño ampliado del operando es el número de bits determinado, porque no es una operación rbp, la operación donde los datos es de 64 bits, 8 bytes necesitan ser extendido, si se opera ebp, entonces sólo Solo expande 4 bytes. Luego, %rbpel contenido del registro será empujado a la pila.

Las instrucciones posteriores son similares a las instrucciones anteriores. Puedes analizarlo tú mismo.

Función devuelve

Lo importante que se debe explicar aquí es cómo la función llamada vuelve a la función llamante, y el valor devuelto también se puede devolver.

pop %rbpInstrucción, que push %rbpes un par con la primera instrucción, introduce los datos en la pila en el registro. Podemos ver que no hay una %rspdirección extendida en este código , pero la variable local es almacenada por la dirección de memoria en la pila, porque la pila es solo una dirección de memoria utilizable. Entonces, este código, las direcciones almacenadas en %rspy %rbpen todo el proceso son las mismas.

pop %rbpSe puede dividir en el siguiente código para comprender:

  1. add 0x8,%rsp
  2. mov -0x8(%rsp), %rbp

Entonces, lo que se saca aquí es la dirección de la parte superior de la pila de la función de llamada.

El valor de retorno de la función se %raxtransfiere a través del registro, por lo que el returnvalor de retorno posterior se coloca en %raxél. Si el registro no se puede poner, se almacenará en la pila, y luego la dirección donde se almacena el valor de retorno se almacenará en el registro. Se puede almacenar en otra memoria.

Finalmente, hablemos de la retqinstrucción. Podemos dividir la instrucción en las siguientes instrucciones:

  1. add 0x8,%rsp
  2. mov -0x8(%rsp),%rip

Esta instrucción es almacenar la dirección de retorno previamente almacenada en la pila en el registro de la PC, para que el registro pueda ser devuelto a la función original para su ejecución.

Estructura de la pila

Dijimos mucho más arriba, pero ninguno de ellos tiene una estructura de pila completa, por lo que primero dibujamos una estructura de pila:

Expandir

Como mi idioma principal actual es Java, lo compararé con la Máquina virtual Java (JVM). Porque la máquina virtual Java también puede considerarse como una computadora virtual.

En la estructura JVM, también hay un registro de PC. El registro de PC también es la dirección para almacenar la siguiente instrucción, que %ripes exactamente la misma que el registro. Entonces, si comprende cómo la CPU ejecuta una instrucción, entonces el principio y el principio del registro de PC en la JVM El papel puede ser entendido.

Entonces, ¿cuál es la diferencia entre una computadora real y una JVM?

De acuerdo con el conocimiento anterior, podemos saber que la computadora ejecuta un método. Al hacer algunos cálculos y operaciones lógicas, los datos de origen se obtienen a través de registros, y los resultados de los cálculos se almacenan en registros. Este es un modelo de ejecución basado en registros.

La JVM no es un modelo de ejecución basado en el registro, sino un modelo de ejecución basado en la pila. Todos los que hayan visto una "comprensión profunda de la máquina virtual Java" saben que cada subproceso de la JVM tendrá un área llamada pila de máquina virtual. La pila de máquina virtual tiene la misma función que la pila mencionada anteriormente, y es cuando se ejecutan métodos Se genera un marco de pila de la función llamada, y la información relacionada se almacena en el marco de pila. Sin embargo, los datos de origen para que la JVM realice cálculos y operaciones lógicas no se adquieren y almacenan a través de registros, sino que se operan a través de un marco de pila denominado pila de operandos. Todos los datos deben pasar a través de la pila de operandos para funcionar.

De hecho, es inseparable de su secta, no importa cómo cambie la capa superior, el principio de la capa inferior permanece sin cambios después de todo. Se trata de cambiar la sopa y no cambiar la medicina, por lo que dominar los principios subyacentes es muy útil para aprender algunas nuevas tecnologías. Puede que no tenga ningún efecto a corto plazo, pero a largo plazo, la ayuda debe ser excelente, por lo que aún debe aprender .

Referencias

[1] Wang Shuang. "Lenguaje ensamblador" Tsinghua University Press

[2] Randal E. Bryant / David O'Hallaron. "Comprensión profunda de los sistemas informáticos tercera edición" Machinery Industry Press

Supongo que te gusta

Origin juejin.im/post/5e9be14df265da480e68ea73
Recomendado
Clasificación