¿Cómo funciona una computadora?: ¿Qué pasa cuando pulso la tecla A? – Práctica
En la primera parte expliqué qué sucedía en un ordenador cuando pulsabas una tecla A de una forma algo más genérica y teórica. En esta segunda parte intentaré describir un ejemplo algo más práctico para que lo comprendas de forma más intuitiva. Por supuesto con ejemplos desde Linux y con códigos en lenguaje de programación C como suelo venir haciendo…
Con el anterior artículo, si eres principiante en esto, obtendrás una visión más amplia de lo que sucede cuando presionas una tecla. Pero ahora toca demostrar con ejemplos prácticos lo que se cuece dentro de tu equipo cada vez que se realiza alguna acción. Para eso también me ayudaré de algunos códigos en ASM (ensamblador), comandos, etc., donde puedes visualizar in-situ lo que va ocurriendo…
Ejemplo práctico de lo que sucede en Linux
Ya deberías tener más o menos claro el sistema de E/S para un programa simple en C si has leído mis tutoriales de programación. Cuando hablo de entrada me refiero a que se puede alimentar el programa con algunos datos, como puede ser una pulsación de alguna tecla desde el teclado.
En cambio, la salida es lo contrario, se muestran datos en algún dispositivo de salida. Por ejemplo se manda a imprimir, o se muestra por la pantalla. Ya sabrás también que stdin (entrada estándar) en Linux es el teclado, y stdout (la salida estándar) es el monitor o pantalla.
Cuando en los ejemplos de Hola Mundo en C escribía printf(«Hola mundo\n»); no es más que otra forma de indicarle al ordenador fprintf ( stdout, «Hola mundo\n»); solo que en este segundo caso se usa el puntero hacia esa salida estándar de la que hablo.
En lenguaje C se podría jugar con varios ejemplos, como este sencillo para poder comprobar qué ocurre cuando presionas una tecla A:
#include <stdio.h> int main() { int tecla; printf("Presiona la tecla A:"); tecla = getchar (); printf("\nHas pulsado: "); putchar (tecla); return 0; }
Es un código simple en el que se te pide pulsar una tecla, en este caso A, y se te muestra en pantalla la tecla pulsada. Ahora toca compilarlo con gcc como te mostré en el tutorial dedicado a ello. Y al ejecutarlo verás el resultado, que sería algo así:
Bien, pero esto no es lo que me interesa. Sería más interesante ver el código ensamblador correspondiente a este pequeño código (puedes usar la opción -S del compilador gcc), para ver qué necesita ejecutar la CPU. Dicho código para AMD64 o x86-64 o EM64T, como lo quieras llamar, sería:
.file "codigo.c" .text .section .rodata .LC0: .string "Presiona la tecla A:" .LC1: .string "\nHas pulsado: \n" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp leaq .LC0(%rip), %rdi movl $0, %eax call printf@PLT call getchar@PLT movl %eax, -4(%rbp) leaq .LC1(%rip), %rdi call puts@PLT movl -4(%rbp), %eax movl %eax, %edi call putchar@PLT movl $0, %eax leave .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0" .section .note.GNU-stack,"",@progbits
De ese código no me interesa la mayor parte del código, solo las instrucciones call. ¿Recuerdas cuando comenté en la primera parte teórica sobre las syscalls o llamadas al sistema? Si lo recuerdas, en Linux se pueden invocar llamadas al sistema desde el código ensamblador de varias formas como con la instrucción int 0x80, como también con syscall, y con call como en este caso. La interrupción int es más antigua para x86, mientras que desde x86-64 se introdujo la instrucción nueva syscall. Y ahora también se puede usar la biblioteca call. En éste último caso se pueden llamar así a funciones, pero es algo más complejo, de hecho, si te fijas usa un PLT (Procedure Linckage Table), es decir, una forma de apuntar a una sección pequeña dentro de un proceso…
Si te centras en el código tecla = getchar(); correspondería a la siguiente porción de ASM:
call getchar@PLT movl %eax, -4(%rbp)
Pero es algo más complejo de ver por esta biblioteca (me refiero a call). Pero piensa que se está usando la biblioteca stdio.h de C, y dentro están definidas las funciones como printf (imprime a través de stdout), putchar (imprime caracteres en la stdout), getchar (obtiene caracteres de la stdin) que se han usado. Todo eso necesita de una serie de procesos de hardware y recursos que no puede manejar el propio programa, sino que lo debe gestionar el kernel como dije en el artículo anterior. Es en este snippet de código donde se realizaría dicha petición…
Recuerda que con el comando ldd ./codigo puedes ver las bibliotecas que usa este código, si le has llamado «codigo» como en mi caso…
Otra prueba que se puede hacer es ejecutar el programa y en otra ventana del terminal ejecutar esto para obtener información del proceso correspondiente a nuestro programa:
ps aux | grep codigo
Y la salida que me da en mi caso es:
isaac 6960 0.0 0.0 4504 720 pts/0 S+ 12:54 0:00 ./codigo
Es decir, que el PID=6960, que pertenece a mi usuario isaac, que está esperando a un evento y que está en primer plano (S+), hora, etc. Con el PID se puede obtener más información que es a lo que voy. Por ejemplo, puedes ir a:
cd /proc/6960 ls
Allí contarás toda la información sobre este proceso, incluída alguna muy interesante de la memoria. ¿Como qué? Pues por ejemplo:
- /proc/6960/cmdline: si usas el concatenador (cat) con este fichero podrás ver el nombre del programa. En este caso sería codigo.
- /proc/6960/cwd: enlace simbólico al directorio de trabajo del proceso.
- /proc/6960/environ: contiene nombres y variables de entorno que afectan a este proceso.
- /proc/6960/exe: enlace simbólico hacia el ejecutable original.
- /proc/6960/fd: directorio con enlace simbólico para el descriptor de archivo o FD.
- /proc/6960/fdinfo: directorio de entradas que describen la posición y flags para el FD.
- /proc/6960/maps: contiene información sobre el mapeo de ficheros y bloques.
- /proc/6960/mem: binario que representa la memoria virtual para el proceso.
- /proc/6960/status: información del estado del proceso y uso de memoria.
- /proc/6960/task: enlace duro hacia cualquier tarea necesaria.
Realmente toda esta información te da una idea de cómo está tratando la CPU al proceso, así como los descriptores y el uso de la memoria para este proceso. Quizás esto tampoco te dice mucho como ocurría con el ASM, pero piensa en lo que te comenté en la parte teórica de este artículo cuando mostré lo de stdin, stdout, los ficheros /dev/hidrawX, etc., y comienza a atar cabos complementándolo con esta otra parte…
Continúo para que puedas «ver» cosas sobre este código para saber mejor lo que está ocurriendo en tu ordenador. Y strace puede ser una buena herramienta para seguir indagando:
#Durante la ejecución del proceso codigo, puedes usar desde otra ventana del terminal sudo strace -p 6960
Eso te puede aportar interesante información del proceso y lo que va sucediendo. Con una salida similar a:
strace: Process 6960 attached read(0, "a\n", 1024) = 2 write(1, "\nHas pulsado: \n", 15) = 15 write(1, "\n", 1) = 1 write(1, "a", 1) = 1 lseek(0, -1, SEEK_CUR) = -1 ESPIPE (Illegal seek) exit_group(0) = ? +++ exited with 0 +++
En este caso, la segunda linea se quedará a la espera de que pulses alguna tecla. Y al pulsar A se completa la línea y luego aparece el resto de líneas correspondientes a la imrpesió o escritura del mensaje del printf y mostrar la letra, etc. Si te fijas, write y read son llamadas al sistema. Lo que le sigue entre paréntesis son los argumentos pasados a la llamada y el número = x sería el valor retornado por la syscall.
Otra opción es usar estas otras opciones:
sudo strace -c -p 6960
Con una salida como:
strace: Process 6960 attached % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 0.00 0.000000 0 1 read 0.00 0.000000 0 3 write 0.00 0.000000 0 1 1 lseek ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000000 5 1 total
En este caso es un sumario del proceso, con el tiempo empleado, la cantidad de llamadas y el tipo.
Si quieres ver el puntero de instrucciones, puedes usar esta otra opción:
sudo strace -i ./codigo
Con una salida tipo:
[00007f5532f4de37] execve("./codigo", ["./codigo"], 0x7ffc7d8149f8 /* 17 vars */) = 0 [00007fec2887eec9] brk(NULL) = 0x5610f5ebb000 [00007fec288727de] access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) [00007fec2887fe27] access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) [00007fec2887fcdd] openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 [00007fec2887fc43] fstat(3, {st_mode=S_IFREG|0644, st_size=115158, ...}) = 0 [00007fec2887ff43] mmap(NULL, 115158, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fec28a6d000 [00007fec2887fed7] close(3) = 0 [00007fec2887b139] access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) [00007fec2887fcdd] openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 [00007fec2887fda4] read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832 [00007fec2887fc43] fstat(3, {st_mode=S_IFREG|0755, st_size=2030544, ...}) = 0 [00007fec2887ff43] mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fec28a6b000 [00007fec2887ff43] mmap(NULL, 4131552, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fec28472000 [00007fec2887fff7] mprotect(0x7fec28659000, 2097152, PROT_NONE) = 0 [00007fec2887ff43] mmap(0x7fec28859000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7fec28859000 [00007fec2887ff43] mmap(0x7fec2885f000, 15072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fec2885f000 [00007fec2887fed7] close(3) = 0 [00007fec28864024] arch_prctl(ARCH_SET_FS, 0x7fec28a6c4c0) = 0 [00007fec2887fff7] mprotect(0x7fec28859000, 16384, PROT_READ) = 0 [00007fec2887fff7] mprotect(0x5610f4cc3000, 4096, PROT_READ) = 0 [00007fec2887fff7] mprotect(0x7fec28a8a000, 4096, PROT_READ) = 0 [00007fec2887ffd7] munmap(0x7fec28a6d000, 115158) = 0 [00007fec285817c3] fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 2), ...}) = 0 [00007fec285884b9] brk(NULL) = 0x5610f5ebb000 [00007fec285884b9] brk(0x5610f5edc000) = 0x5610f5edc000 [00007fec285817c3] fstat(0, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 2), ...}) = 0 [00007fec28582154] write(1, "Presiona la tecla A:", 20Presiona la tecla A:) = 20 [00007fec28582081] read(0, a "a\n", 1024) = 2 [00007fec28582154] write(1, "\nHas pulsado: \n", 15 Has pulsado: ) = 15 [00007fec28582154] write(1, "\n", 1 ) = 1 [00007fec28582154] write(1, "a", 1a) = 1 [00007fec28582217] lseek(0, -1, SEEK_CUR) = -1 ESPIPE (Illegal seek) [00007fec28556e06] exit_group(0) = ? [????????????????] +++ exited with 0 +++
Esto lo que muestra es cada llamada realizada por el programa, con el instruction pointer (registro PC ¿recuerdas?, siente instrucción a ejecutar…) correspondiente. Es decir, lo que aparece al comiento son direcciones de memoria donde se apunta para ejecutar esto.
Puedes incluso filtrar para solo ver información de una llamada específica, por ejemplo:
sudo strace -e trace=read
Por cierto, si recuerdas el artículo sobre el depurador gdb, también te podría dar datos interesantes de lo que va sucediendo…