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.c
está 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 --version
Si 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./m
Indica que el código fuente y el código de ensamblaje están dispuestos juntos,/r
lo 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, queinfo
puede abreviarse comoi
,register
puede 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,next
saltaremos 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.n
Indica el número de celdas de memoria que se mostrarán, que puede ser un número decimal directof
Representa el modo de visualización, puede tomar los siguientes valoresx
: Mostrar variables en hexadecimald
: Mostrar variables en decimalu
: Mostrar enteros sin signo en decimalo
: Mostrar variables en formato octalt
: Mostrar variables en formato binario- .......
u
Representa la longitud de una unidad de dirección, puede tomar los siguientes valoresb
: Indica un solo byteh
: Indica bytes doblesw
: Indica cuatro bytesg
: Indica ocho bytes
p var
: Indica el valor de la variable que se va a ver, quevar
es 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 main
depuración y luego start
comenzamos 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 %rbp
que %rbp
representa 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 :, %eax
luego operamos los 32 bits inferiores del primer registro; luego, operamos %ax
los 16 bits inferiores del %rbx
primer 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 %rsp
registro, que almacena la dirección de la parte superior de la pila. Sabemos que %rsp
puede 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 %rsp
la 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 %rbp
valor en el registro a la pila, y %rbp
el 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
:
mov
La instrucción contiene dos operandos, que son el operando de origen y el operando de destino mov src, desc
, que es %rsp
pasar 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.
%rsp
El 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 %rbp
el 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, %rbp
el 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 r
ver 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 %rbp
que %rsp
los 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
:
sub
La instrucción es una instrucción de resta, correspondiente a la add
instrucción, y también tiene dos operandos, esta instrucción se traduce a un lenguaje de alto nivel %rsp = %rsp - 0x10
. Aquí está %rsp
el 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 %rsp
registro almacena la dirección de la parte superior de la pila, por lo que si desea usar la pila, debe Haga que %rsp
la 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 %rsp
el valor en este momento debería ser 0x7fffffffde40
. i r
Verifiquemos 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 %rbp
y respectivamente , %rsp
y son exactamente las mismas que calculamos.
La cuarta compilación:movl $0x1,-0xc(%rbp)
¿Es esta instrucción mov
muy similar a la instrucción? En realidad es mov
una extensión de la movl
instrucció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 %rbp
la 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í %rbp
puede 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 %rbp
direcció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 0x7fffffffde44
los 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 %rbp
la 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 0x00000001
datos 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 %edx
registro, %eax
registro.
Entonces observado después de dos instrucciones, estarán simplemente %edx
, %eax
el 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
, rsi
para 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 callq
que 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 rip
registro (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 /r
Esta 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),%edx
y 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 callq
instrucciones, 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 0x000055555555464c
y luego la CPU ejecutará la instrucción.
¿Qué hace la CPU al ejecutar esta instrucción? Podemos desglosar esta instrucción:
sub 0x8, %rsp
push %rip
mov 0x00005555555545fa,%rip
Cuando la CPU ejecuta la call
instrucció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á rip
el valor en el registro actual a la pila, es decir, guardar La unidad de memoria de 8 bytes acaba de expandirse y, finalmente, %rip
el valor del registro se cambia al valor del operando.
Y 0x00005555555545fa
es 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 call
el operando de la instrucción.
Pensemos en otra pregunta ahora, ¿por qué deberíamos poner rip
la 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 rip
para continuar ejecutando las instrucciones posteriores de la función de llamada.
Aquí hay un suplemento para push
el análisis de la instrucción: si se ejecuta push %rbp
, la instrucción se puede dividir en las siguientes instrucciones para comprender:
sub 0x8,%rsp
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, %rbp
el 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 %rbp
Instrucción, que push %rbp
es un par con la primera instrucción, introduce los datos en la pila en el registro. Podemos ver que no hay una %rsp
direcció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 %rsp
y %rbp
en todo el proceso son las mismas.
pop %rbp
Se puede dividir en el siguiente código para comprender:
add 0x8,%rsp
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 %rax
transfiere a través del registro, por lo que el return
valor 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 retq
instrucción. Podemos dividir la instrucción en las siguientes instrucciones:
add 0x8,%rsp
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 %rip
es 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