c++ - una - tipos de funciones en lenguaje c
Definiciones de funciones C/C++ sin ensamblaje (7)
Bueno, todas las sentencias de C ++ excepto el punto y coma y los comentarios se convierten en códigos de máquina que le dicen a la CPU qué hacer. Puede escribir su propia función printf sin recurrir al ensamblaje. Las únicas operaciones que se deben escribir en el ensamblaje son las entradas y salidas de los puertos, y las cosas que habilitan y deshabilitan las interrupciones.
Sin embargo, el ensamblaje aún se usa en la programación a nivel del sistema por motivos de rendimiento. Aunque el ensamblaje en línea no es compatible, no hay nada que le impida escribir un módulo por separado en el ensamblaje y vincularlo a su aplicación.
Siempre pensé que las funciones como printf()
se definen, en el último paso, mediante el ensamblaje en línea. Que en lo profundo de las entrañas de stdio.h está enterrado un código asm que realmente le dice a la CPU qué hacer. Por ejemplo, en dos, recuerdo que se implementó mov
primero el comienzo de la cadena a alguna ubicación de memoria o registro y luego de llamar a un intupupt.
Sin embargo, dado que la versión x64 de Visual Studio no es compatible en absoluto con el ensamblador en línea, me hizo preguntarme cómo no podría haber ninguna función definida por el ensamblador en C / C ++. ¿Cómo se implementa una biblioteca como printf()
en C / C ++ sin usar código de ensamblador? ¿Qué es lo que realmente ejecuta la interrupción de software correcta? Gracias.
El compilador genera el ensamblado a partir del código fuente de C / C ++.
En Linux, la utilidad strace
permite ver qué llamadas al sistema realiza un programa. Entonces, tomando un programa como este
int main(){ printf("x"); return 0; }
Digamos que lo compila como printx
, luego strace printx
da
execve("./printx", ["./printx"], [/* 49 vars */]) = 0 brk(0) = 0xb66000 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa6dc0e5000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=119796, ...}) = 0 mmap(NULL, 119796, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fa6dc0c7000 close(3) = 0 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "/177ELF/2/1/1/0/0/0/0/0/0/0/0/0/3/0>/0/1/0/0/0/200/30/2/0/0/0/0/0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1811128, ...}) = 0 mmap(NULL, 3925208, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fa6dbb06000 mprotect(0x7fa6dbcbb000, 2093056, PROT_NONE) = 0 mmap(0x7fa6dbeba000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1b4000) = 0x7fa6dbeba000 mmap(0x7fa6dbec0000, 17624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fa6dbec0000 close(3) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa6dc0c6000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa6dc0c5000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa6dc0c4000 arch_prctl(ARCH_SET_FS, 0x7fa6dc0c5700) = 0 mprotect(0x7fa6dbeba000, 16384, PROT_READ) = 0 mprotect(0x600000, 4096, PROT_READ) = 0 mprotect(0x7fa6dc0e7000, 4096, PROT_READ) = 0 munmap(0x7fa6dc0c7000, 119796) = 0 fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa6dc0e4000 write(1, "x", 1x) = 1 exit_group(0) = ?
La goma se encuentra con la carretera (ordenar, ver más abajo) en la penúltima llamada del trazado: write(1,"x",1x)
. En este punto, el control pasa de user-land printx
al kernel de Linux que maneja el resto. write()
es una función de contenedor declarada en unistd.h
extern ssize_t write (int __fd, __const void *__buf, size_t __n) __wur;
La mayoría de las llamadas al sistema están envueltas de esta manera. La función de envoltura, como su nombre lo indica, es poco más que una delgada capa de código que coloca los argumentos en los registros correctos y luego ejecuta una interrupción de software 0x80. El kernel atrapa la interrupción y el resto es historia. O al menos así es como solía funcionar. Aparentemente, la sobrecarga de la captura de interrupción era bastante alta y, como se señaló en una publicación anterior, las arquitecturas de CPU modernas introdujeron las instrucciones de ensamblaje de sysenter
, que logran el mismo resultado a gran velocidad. Esta página Llamadas del sistema tiene un buen resumen de cómo funcionan las llamadas al sistema.
Creo que probablemente estaremos un poco decepcionados con esta respuesta, al igual que yo. Claramente, en cierto sentido, esto es un fondo falso, ya que todavía hay bastantes cosas que tienen que suceder entre la llamada a write()
y la punto en el que el búfer del cuadro de la tarjeta gráfica se modifica para hacer aparecer la letra "x" en la pantalla. Acercarse al punto de contacto (para seguir con la analogía del "caucho contra el camino") al sumergirse en el kernel seguramente será educativo si lleva mucho tiempo. Supongo que tendrías que atravesar varias capas de abstracción, como flujos de salida en búfer, dispositivos de personajes, etc. Asegúrate de publicar los resultados si decides seguir con esto :)
En general, la función de biblioteca se precompila y distribuye el objeto publicitario. El ensamblador en línea se usa solo en situaciones particulares por razones de rendimiento, pero es la excepción, no la regla. En realidad, printf no me parece un buen candidato para ensamblar en línea. Insetada, funciones como memcpy o memcmp. Las funciones de muy bajo nivel pueden ser compiladas por un ensamblador nativo (masm? Gnu asm?) Y distribuirse como un objeto en una biblioteca.
Las funciones de la biblioteca estándar se implementan en una biblioteca de plataforma subyacente (por ejemplo, la API UNIX) y / o mediante llamadas directas al sistema (que todavía son funciones C). Las llamadas al sistema son (en plataformas que conozco) implementadas internamente por una llamada a una función con asm en línea que pone un número de llamada del sistema y parámetros en los registros de la CPU y desencadena una interrupción que el kernel luego procesa.
También hay otras maneras de comunicarse con el hardware, además de las llamadas de sistema, pero éstas generalmente no están disponibles o son bastante limitadas cuando se ejecutan bajo un sistema operativo moderno, o al menos habilitarlas requiere algunas llamadas de sistema. Un dispositivo puede estar mapeado en la memoria, por lo que escribe en ciertas direcciones de memoria (a través de punteros regulares) para controlar el dispositivo. Los puertos de E / S también se utilizan a menudo y, dependiendo de la arquitectura, se accede a ellos mediante códigos de operación especiales de la CPU o también pueden mapearse en la memoria a direcciones específicas.
Por supuesto, tienes razón en que el caucho debe encontrarse con la carretera en algún momento. ¡Pero hay muchas capas por atravesar antes de que puedas encontrar ese lugar! Parece que tienes algunas ideas preconcebidas basadas en los días del DOS, y eso ya no es demasiado relevante.
Se han hecho algunos buenos puntos generales aquí, pero nadie ha vinculado a los demonios precisos en los detalles de la fuente. Entonces, para hacerte sentir lo suficiente como para haber preguntado :) Hice un trazo completo de la historia de printf
para la libc y Linux de GNU ... intentando no agitar manualmente ninguno de los pasos. En el proceso, actualicé parte de mi propio conocimiento (ADVERTENCIA: ¡No es para aburrirse fácilmente!):
(El enlace original es http://blog.hostilefork.com/where-printf-rubber-meets-road/ , y se mantendrá allí. Pero para evitar que el enlace se pudra aquí se almacena el contenido).
Primeros pasos
Por supuesto, comenzaremos con el prototipo de printf, que se define en el archivo libc/libio/stdio.h
extern int printf (__const char *__restrict __format, ...);
Sin embargo, no encontrará el código fuente para una función llamada printf. En cambio, en el archivo /libc/stdio-common/printf.c
encontrará un pequeño código asociado con una función llamada __printf
:
int __printf (const char *format, ...)
{
va_list arg;
int done;
va_start (arg, format);
done = vfprintf (stdout, format, arg);
va_end (arg);
return done;
}
Una macro en el mismo archivo establece una asociación para que esta función se defina como un alias para printf no subrayado:
ldbl_strong_alias (__printf, printf);
Tiene sentido que printf sea una capa delgada que llama a vfprintf con stdout. De hecho, la parte principal del trabajo de formateo se realiza en vfprintf, que encontrará en libc/stdio-common/vfprintf.c
. Es una función bastante larga, ¡pero se puede ver que todavía está en C!
Más profundo en el agujero del conejo ...
vfprintf llama misteriosamente a outchar y outstring, que son macros extrañas definidas en el mismo archivo:
#define outchar(Ch) /
do /
{ /
register const INT_T outc = (Ch); /
if (PUTC (outc, s) == EOF || done == INT_MAX) /
{ /
done = -1; /
goto all_done; /
} /
++done; /
} /
while (0)
Sidestepping la pregunta de por qué es tan extraño, vemos que depende del enigmático PUTC, también en el mismo archivo:
#define PUTC(C, F) IO_putwc_unlocked (C, F)
Cuando llegue a la definición de IO_putwc_unlocked
en libc/libio/libio.h
, puede comenzar a pensar que ya no le importa cómo funciona printf:
#define _IO_putwc_unlocked(_wch, _fp) /
(_IO_BE ((_fp)->_wide_data->_IO_write_ptr /
>= (_fp)->_wide_data->_IO_write_end, 0) /
? __woverflow (_fp, _wch) /
: (_IO_wint_t) (*(_fp)->_wide_data->_IO_write_ptr++ = (_wch)))
Pero a pesar de ser un poco difícil de leer, solo está haciendo una salida en búfer. Si hay espacio suficiente en el búfer del puntero del archivo, entonces simplemente insertará el carácter en él ... pero si no, llama a __woverflow
. Dado que la única opción cuando te quedas sin buffer es ir a la pantalla (o al dispositivo que represente el puntero de tu archivo), podemos esperar encontrar el hechizo mágico allí.
Vtables en C?
Si adivinaste que vamos a pasar por otro frustrante nivel de indirección, estarías en lo cierto. Busque en libc / libio / wgenops.c y encontrará la definición de __woverflow
:
wint_t
__woverflow (f, wch)
_IO_FILE *f;
wint_t wch;
{
if (f->_mode == 0)
_IO_fwide (f, 1);
return _IO_OVERFLOW (f, wch);
}
Básicamente, los punteros de archivo se implementan en la biblioteca estándar de GNU como objetos. Tienen miembros de datos pero también miembros de funciones a los que puedes llamar con variaciones de la macro JUMP. En el archivo libc/libio/libioP.h
encontrará una pequeña documentación de esta técnica:
/* THE JUMPTABLE FUNCTIONS.
* The _IO_FILE type is used to implement the FILE type in GNU libc,
* as well as the streambuf class in GNU iostreams for C++.
* These are all the same, just used differently.
* An _IO_FILE (or FILE) object is allows followed by a pointer to
* a jump table (of pointers to functions). The pointer is accessed
* with the _IO_JUMPS macro. The jump table has a eccentric format,
* so as to be compatible with the layout of a C++ virtual function table.
* (as implemented by g++). When a pointer to a streambuf object is
* coerced to an (_IO_FILE*), then _IO_JUMPS on the result just
* happens to point to the virtual function table of the streambuf.
* Thus the _IO_JUMPS function table used for C stdio/libio does
* double duty as the virtual function table for C++ streambuf.
*
* The entries in the _IO_JUMPS function table (and hence also the
* virtual functions of a streambuf) are described below.
* The first parameter of each function entry is the _IO_FILE/streambuf
* object being acted on (i.e. the ''this'' parameter).
*/
Entonces, cuando encontramos IO_OVERFLOW
en libc/libio/genops.c
, encontramos que es una macro que llama a un método de “1-parameter” __overflow
en el puntero del archivo:
#define IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)
Las tablas de salto para los diversos tipos de puntero de archivo están en libc / libio / fileops.c
const struct _IO_jump_t _IO_file_jumps =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, INTUSE(_IO_file_finish)),
JUMP_INIT(overflow, INTUSE(_IO_file_overflow)),
JUMP_INIT(underflow, INTUSE(_IO_file_underflow)),
JUMP_INIT(uflow, INTUSE(_IO_default_uflow)),
JUMP_INIT(pbackfail, INTUSE(_IO_default_pbackfail)),
JUMP_INIT(xsputn, INTUSE(_IO_file_xsputn)),
JUMP_INIT(xsgetn, INTUSE(_IO_file_xsgetn)),
JUMP_INIT(seekoff, _IO_new_file_seekoff),
JUMP_INIT(seekpos, _IO_default_seekpos),
JUMP_INIT(setbuf, _IO_new_file_setbuf),
JUMP_INIT(sync, _IO_new_file_sync),
JUMP_INIT(doallocate, INTUSE(_IO_file_doallocate)),
JUMP_INIT(read, INTUSE(_IO_file_read)),
JUMP_INIT(write, _IO_new_file_write),
JUMP_INIT(seek, INTUSE(_IO_file_seek)),
JUMP_INIT(close, INTUSE(_IO_file_close)),
JUMP_INIT(stat, INTUSE(_IO_file_stat)),
JUMP_INIT(showmanyc, _IO_default_showmanyc),
JUMP_INIT(imbue, _IO_default_imbue)
};
libc_hidden_data_def (_IO_file_jumps)
También hay un #define que equivale a _IO_new_file_overflow
con _IO_file_overflow
, y el primero se define en el mismo archivo fuente. (Nota: INTUSE es solo una macro que marca funciones que son para uso interno, no significa algo como "esta función usa una interrupción")
¡¿Ya llegamos?!
El código fuente de _IO_new_file_overflow hace un montón más de manipulación del búfer, pero llama _IO_do_flush
:
#define _IO_do_flush(_f) /
INTUSE(_IO_do_write)(_f, (_f)->_IO_write_base, /
(_f)->_IO_write_ptr-(_f)->_IO_write_base)
Ahora estamos en un punto donde _IO_do_write es probablemente donde la goma se encuentra con la carretera: una escritura directa, no amortiguada, real en un dispositivo de E / S. Al menos podemos esperar! Está mapeado por una macro a _IO_new_do_write y tenemos esto:
static
_IO_size_t
new_do_write (fp, data, to_do)
_IO_FILE *fp;
const char *data;
_IO_size_t to_do;
{
_IO_size_t count;
if (fp->_flags & _IO_IS_APPENDING)
/* On a system without a proper O_APPEND implementation,
you would need to sys_seek(0, SEEK_END) here, but is
is not needed nor desirable for Unix- or Posix-like systems.
Instead, just indicate that offset (before and after) is
unpredictable. */
fp->_offset = _IO_pos_BAD;
else if (fp->_IO_read_end != fp->_IO_write_base)
{
_IO_off64_t new_pos
= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
if (new_pos == _IO_pos_BAD)
return 0;
fp->_offset = new_pos;
}
count = _IO_SYSWRITE (fp, data, to_do);
if (fp->_cur_column && count)
fp->_cur_column = INTUSE(_IO_adjust_column) (fp->_cur_column - 1, data,
count) + 1;
_IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
fp->_IO_write_end = (fp->_mode <= 0
&& (fp->_flags & (_IO_LINE_BUF+_IO_UNBUFFERED))
? fp->_IO_buf_base : fp->_IO_buf_end);
return count;
}
Lamentablemente estamos atrapados de nuevo ... _IO_SYSWRITE
está haciendo el trabajo:
/* The ''syswrite'' hook is used to write data from an existing buffer
to an external file. It generalizes the Unix write(2) function.
It matches the streambuf::sys_write virtual function, which is
specific to this implementation. */
typedef _IO_ssize_t (*_IO_write_t) (_IO_FILE *, const void *, _IO_ssize_t);
#define _IO_SYSWRITE(FP, DATA, LEN) JUMP2 (__write, FP, DATA, LEN)
#define _IO_WSYSWRITE(FP, DATA, LEN) WJUMP2 (__write, FP, DATA, LEN)
Entonces, dentro de do_write llamamos al método de escritura en el puntero del archivo. Sabemos por nuestra tabla de salto de arriba que está mapeada a _IO_new_file_write, entonces ¿qué es eso?
_IO_ssize_t
_IO_new_file_write (f, data, n)
_IO_FILE *f;
const void *data;
_IO_ssize_t n;
{
_IO_ssize_t to_do = n;
while (to_do > 0)
{
_IO_ssize_t count = (__builtin_expect (f->_flags2
& _IO_FLAGS2_NOTCANCEL, 0)
? write_not_cancel (f->_fileno, data, to_do)
: write (f->_fileno, data, to_do));
if (count < 0)
{
f->_flags |= _IO_ERR_SEEN;
break;
}
to_do -= count;
data = (void *) ((char *) data + count);
}
n -= to_do;
if (f->_offset >= 0)
f->_offset += n;
return n;
}
¡Ahora solo llama a escribir! Bueno, ¿dónde está la implementación para eso? Encontrarás escribir en libc/posix/unistd.h
:
/* Write N bytes of BUF to FD. Return the number written, or -1.
This function is a cancellation point and therefore not marked with
__THROW. */
extern ssize_t write (int __fd, __const void *__buf, size_t __n) __wur;
(Nota: __wur
es una macro para __attribute__ ((__warn_unused_result__)))
Funciones generadas a partir de una tabla
Eso es solo un prototipo para escribir. No encontrará un archivo write.c para Linux en la biblioteca estándar de GNU. En su lugar, encontrará métodos específicos de la plataforma para conectarse a la función de escritura del sistema operativo de varias maneras, todo en el directorio libc / sysdeps /.
Continuaremos siguiendo junto con cómo Linux lo hace. Hay un archivo llamado sysdeps/unix/syscalls.list
que se usa para generar la función de escritura automáticamente. Los datos relevantes de la tabla son:
File name: write
Caller: “-” (i.e. Not Applicable)
Syscall name: write
Args: Ci:ibn
Strong name: __libc_write
Weak names: __write, write
No es tan misterioso, a excepción del Ci:ibn
. La C significa "cancelable". El dos puntos separa el tipo de devolución de los tipos de argumentos, y si desea una explicación más profunda de lo que significan, entonces puede ver el comentario en el script de shell que genera el código, libc/sysdeps/unix/make-syscalls.sh
.
Entonces ahora esperamos poder enlazar con una función llamada __libc_write que es generada por este script de shell. Pero, ¿qué se está generando? Algún código C que implementa escritura a través de una macro llamada SYS_ify, que encontrará en sysdeps / unix / sysdep.h
#define SYS_ify(syscall_name) __NR_##syscall_name
Ah, bueno viejo token-pasting: P. Entonces, básicamente, la implementación de este __libc_write
convierte en nada más que una invocación de proxy de la función syscall con un parámetro llamado __NR_write
y los otros argumentos.
Donde termina la acera ...
Sé que este ha sido un viaje fascinante, pero ahora estamos al final de GNU libc. Ese número __NR_write
está definido por Linux. Para las arquitecturas X86 de 32 bits, te llevará a linux/arch/x86/include/asm/unistd_32.h
:
#define __NR_write 4
Lo único que queda por mirar, entonces, es la implementación de syscall. Lo cual puedo hacer en algún momento, pero por ahora solo te indicaré algunas referencias sobre cómo agregar una llamada al sistema a Linux .
Primero, debes entender el concepto de anillos.
Un kernel se ejecuta en el anillo 0, lo que significa que tiene un acceso completo a la memoria y códigos de operación.
Un programa se ejecuta normalmente en el anillo 3. Tiene un acceso limitado a la memoria y no puede usar todos los códigos de operación.
Entonces, cuando un software necesita más privilegios (para abrir un archivo, escribir en un archivo, asignar memoria, etc.), necesita preguntarle al kernel.
Esto puede hacerse de muchas maneras. Interrupciones de software, SYSENTER, etc.
Tomemos el ejemplo de interrupciones de software, con la función printf ():
1 - Su software llama a printf ().
2 - printf () procesa su cadena y args, y luego necesita ejecutar una función de núcleo, ya que escribir en un archivo no se puede hacer en el anillo 3.
3 - printf () genera una interrupción de software, colocando en un registro el número de una función de kernel (en ese caso, la función de escritura ()).
4 - La ejecución del software se interrumpe y el puntero de la instrucción se mueve al código del kernel. Entonces ahora estamos en el anillo 0, en una función kernel.
5 - El kernel procesa la solicitud, escribiendo en el archivo (stdout es un descriptor de archivo).
6 - Cuando termine, el kernel regresa al código del software, usando la instrucción iret.
7 - El código del software continúa.
De modo que las funciones de la biblioteca estándar C se pueden implementar en C. Todo lo que tiene que hacer es saber cómo llamar al kernel cuando necesita más privilegios.